android architecture
npx skills add https://github.com/f0x1d/logfox --skill Android Architecture
Skill 文档
Architecture Skill: Android Application Architecture
This skill defines the architectural rules and patterns for building Android applications in this project. It establishes conventions for state management, layer separation, dependency injection, modularization, and UI patterns using both Views and Jetpack Compose.
1. TEA (The Elm Architecture) – Modified
Overview
The architecture uses a modified TEA pattern with State, Command, and SideEffect. The system must be fully cancellable. The Store is lifecycle-aware via ViewModel. Store.send() must be called only from Main thread.
Components
State
Represents the current state of the feature. Must be immutable.
data class AuthState(
val isLoading: Boolean = false,
val error: String? = null,
val user: User? = null,
)
Command
Represents user actions or system events that can modify state.
sealed interface AuthCommand {
data class LoginTapped(val email: String, val password: String) : AuthCommand
data object LogoutTapped : AuthCommand
data class UserLoaded(val user: User) : AuthCommand
data class ErrorOccurred(val message: String) : AuthCommand
}
SideEffect
Represents side effects that need to be executed. SideEffects serve two purposes:
- Business logic – handled by EffectHandlers (network calls, persistence, etc.)
- UI actions – handled by Fragment/Composable (navigation, toasts, etc.)
sealed interface AuthSideEffect {
// Business logic side effects
data class Login(val email: String, val password: String) : AuthSideEffect
data object Logout : AuthSideEffect
data object LoadUser : AuthSideEffect
// UI side effects
data object NavigateToHome : AuthSideEffect
data class ShowError(val message: String) : AuthSideEffect
}
Reducer Interface
The reducer takes state and a command, returning new state and a list of side effects. Reducer is a pure function – no side effects allowed.
// Interface - in core/tea/base module, public
interface Reducer<State, Command, SideEffect> {
fun reduce(state: State, command: Command): ReduceResult<State, SideEffect>
}
data class ReduceResult<State, SideEffect>(
val state: State,
val sideEffects: List<SideEffect> = emptyList(),
)
// Helper function for cleaner syntax
fun <State, SideEffect> State.withSideEffects(
vararg sideEffects: SideEffect,
): ReduceResult<State, SideEffect> = ReduceResult(this, sideEffects.toList())
fun <State, SideEffect> State.noSideEffects(): ReduceResult<State, SideEffect> =
ReduceResult(this, emptyList())
// Implementation - feature name prefix, internal visibility (NO Impl suffix for reducers)
internal class AuthReducer : Reducer<AuthState, AuthCommand, AuthSideEffect> {
override fun reduce(
state: AuthState,
command: AuthCommand,
): ReduceResult<AuthState, AuthSideEffect> = when (command) {
is AuthCommand.LoginTapped -> {
state.copy(isLoading = true, error = null)
.withSideEffects(AuthSideEffect.Login(command.email, command.password))
}
is AuthCommand.LogoutTapped -> {
state.withSideEffects(AuthSideEffect.Logout)
}
is AuthCommand.UserLoaded -> {
state.copy(isLoading = false, user = command.user)
.withSideEffects(AuthSideEffect.NavigateToHome)
}
is AuthCommand.ErrorOccurred -> {
state.copy(isLoading = false, error = command.message)
.withSideEffects(AuthSideEffect.ShowError(command.message))
}
}
}
EffectHandler Interface
Effect handlers process side effects and send feedback via onCommand suspend function. The onCommand MUST be suspend and internally uses withContext(Dispatchers.Main.immediate) to ensure thread safety since Store.send() must be called from Main thread.
Multiple effect handlers can be specified, each with its own role.
EffectHandler extends Closeable. The default close() is a no-op, but it can be overridden to release resources (e.g., cancel internal jobs). Store.cancel() calls close() on all effect handlers.
// Interface - in core/tea/base module, public
interface EffectHandler<SideEffect, Command>: Closeable {
suspend fun handle(effect: SideEffect, onCommand: suspend (Command) -> Unit)
override fun close() = Unit
}
// Network-related effect handler - feature name prefix, internal (NO Impl suffix)
internal class AuthNetworkEffectHandler @Inject constructor(
private val loginUseCase: LoginUseCase,
private val logoutUseCase: LogoutUseCase,
) : EffectHandler<AuthSideEffect, AuthCommand> {
override suspend fun handle(
effect: AuthSideEffect,
onCommand: suspend (AuthCommand) -> Unit,
) {
when (effect) {
is AuthSideEffect.Login -> {
loginUseCase(effect.email, effect.password)
.onSuccess { user -> onCommand(AuthCommand.UserLoaded(user)) }
.onFailure { error -> onCommand(AuthCommand.ErrorOccurred(error.message ?: "Unknown error")) }
}
is AuthSideEffect.Logout -> {
logoutUseCase()
}
// Handled by different effect handler or UI
else -> Unit
}
}
}
// Persistence-related effect handler
internal class AuthPersistenceEffectHandler @Inject constructor(
private val loadUserUseCase: LoadUserUseCase,
) : EffectHandler<AuthSideEffect, AuthCommand> {
override suspend fun handle(
effect: AuthSideEffect,
onCommand: suspend (AuthCommand) -> Unit,
) {
when (effect) {
is AuthSideEffect.LoadUser -> {
loadUserUseCase()?.let { user ->
onCommand(AuthCommand.UserLoaded(user))
}
}
// Handled by different effect handler or UI
else -> Unit
}
}
}
Store
The store orchestrates state, reducer, and effect handlers. Must support cancellation via coroutine job management. send() must be called only from Main thread.
// In core/tea/base module, public
class Store<State, Command, SideEffect>(
initialState: State,
private val reducer: Reducer<State, Command, SideEffect>,
private val effectHandlers: List<EffectHandler<SideEffect, Command>>,
private val scope: CoroutineScope,
) {
private val _state = MutableStateFlow(initialState)
val state: StateFlow<State> = _state.asStateFlow()
private val _sideEffects = MutableSharedFlow<SideEffect>()
val sideEffects: SharedFlow<SideEffect> = _sideEffects.asSharedFlow()
private val jobs = mutableMapOf<String, Job>()
/**
* Send a command to the store. MUST be called from Main thread.
*/
fun send(command: Command) {
val result = reducer.reduce(_state.value, command)
_state.value = result.state
result.sideEffects.forEach { sideEffect ->
// Emit side effect for UI observation
scope.launch {
_sideEffects.emit(sideEffect)
}
// Process side effect with handlers
effectHandlers.forEach { handler ->
val jobId = UUID.randomUUID().toString()
val job = scope.launch {
handler.handle(sideEffect) { cmd ->
// Switch to Main thread before calling send
withContext(Dispatchers.Main) {
send(cmd)
}
}
jobs.remove(jobId)
}
jobs[jobId] = job
}
}
}
fun cancel() {
jobs.values.forEach { it.cancel() }
jobs.clear()
effectHandlers.forEach { it.close() }
}
}
ViewStateMapper Interface
ViewStateMapper is a mandatory interface that transforms internal domain State into a presentation-ready ViewState. Every feature MUST have a ViewState and a ViewStateMapper – there is no opt-out.
// In core/tea/base module, public
interface ViewStateMapper<State, ViewState> {
fun map(state: State): ViewState
}
Even for simple features where State maps 1:1 to ViewState, the mapper must exist. The mapper keeps the boundary clean and makes it trivial to add derived fields later.
BaseStoreViewModel
Base ViewModel that integrates Store with Android lifecycle. The ViewModel maps internal State to ViewState via the ViewStateMapper and exposes the mapped StateFlow<ViewState>.
// In core/tea/android module, public
abstract class BaseStoreViewModel<ViewState, State, Command, SideEffect>(
initialState: State,
reducer: Reducer<State, Command, SideEffect>,
effectHandlers: List<EffectHandler<SideEffect, Command>>,
viewStateMapper: ViewStateMapper<State, ViewState>,
initialSideEffects: List<SideEffect> = emptyList(),
viewStateMappingDispatcher: CoroutineDispatcher = Dispatchers.Main.immediate,
) : ViewModel() {
private val store = Store(
initialState = initialState,
reducer = reducer,
effectHandlers = effectHandlers,
scope = viewModelScope,
)
val state: StateFlow<ViewState> = store.state
.map { viewStateMapper.map(it) }
.flowOn(viewStateMappingDispatcher)
.stateIn(
scope = viewModelScope,
started = SharingStarted.Eagerly,
initialValue = viewStateMapper.map(initialState),
)
val sideEffects: SharedFlow<SideEffect> = store.sideEffects
init {
initialSideEffects.forEach { effect ->
effectHandlers.forEach { handler ->
viewModelScope.launch {
handler.handle(effect) { cmd ->
withContext(Dispatchers.Main.immediate) {
send(cmd)
}
}
}
}
}
}
fun send(command: Command) {
store.send(command)
}
override fun onCleared() {
super.onCleared()
store.cancel()
}
}
Key points:
ViewStateis the first type parameterstateexposesStateFlow<ViewState>, notStateFlow<State>– the mapping happens inside the ViewModelviewStateMappingDispatcherdefaults toDispatchers.Main.immediatebut can be overridden (e.g., toDispatchers.Default) for expensive mapping operations- Fragments/Composables render
ViewStatedirectly – they do NOT inject the mapper
Feature ViewModel
Feature-specific ViewModel that extends BaseStoreViewModel. The first type parameter is always ViewState.
// Feature ViewModel - internal visibility
@HiltViewModel
internal class AuthViewModel @Inject constructor(
reducer: AuthReducer,
networkEffectHandler: AuthNetworkEffectHandler,
persistenceEffectHandler: AuthPersistenceEffectHandler,
viewStateMapper: AuthViewStateMapper,
) : BaseStoreViewModel<AuthViewState, AuthState, AuthCommand, AuthSideEffect>(
initialState = AuthState(),
reducer = reducer,
effectHandlers = listOf(networkEffectHandler, persistenceEffectHandler),
viewStateMapper = viewStateMapper,
initialSideEffects = listOf(
AuthSideEffect.LoadUser,
AuthSideEffect.ObservePreferences,
),
)
ViewState Pattern (MANDATORY)
Every feature MUST have both a State (internal domain state managed by the Reducer) and a ViewState (presentation-ready state consumed by the UI). The ViewStateMapper transforms State into ViewState inside the ViewModel â the Fragment/Composable only sees ViewState.
Components
State holds domain-centric data managed by the Reducer:
internal data class ItemsState(
val items: List<FormattedItem>? = null,
val selectedIds: Set<Long> = emptySet(),
val expandedOverrides: Map<Long, Boolean> = emptyMap(),
val defaultExpanded: Boolean = false,
val textSize: Int = 14,
val itemsChanged: Boolean = true,
// ... other domain fields
)
ViewState holds presentation-ready data for the Fragment:
internal data class ItemsViewState(
val items: List<ItemPresentationModel>? = null,
val itemsChanged: Boolean = true,
val selecting: Boolean = false,
val selectedCount: Int = 0,
// ... other UI fields
)
ViewStateMapper implements the ViewStateMapper<State, ViewState> interface from core/tea, @Inject-constructed, internal visibility:
internal class ItemsViewStateMapper @Inject constructor() : ViewStateMapper<ItemsState, ItemsViewState> {
override fun map(state: ItemsState): ItemsViewState = ItemsViewState(
items = state.items?.map { formatted ->
formatted.toPresentationModel(
expanded = state.expandedOverrides.getOrElse(formatted.id) { state.defaultExpanded },
selected = formatted.id in state.selectedIds,
textSize = state.textSize.toFloat(),
)
},
itemsChanged = state.itemsChanged,
selecting = state.selectedIds.isNotEmpty(),
selectedCount = state.selectedIds.size,
)
}
For simple features where State maps nearly 1:1 to ViewState, the mapper is still required but trivial:
internal class SearchLogsViewStateMapper @Inject constructor() : ViewStateMapper<SearchLogsState, SearchLogsViewState> {
override fun map(state: SearchLogsState): SearchLogsViewState = SearchLogsViewState(
query = state.query.orEmpty(),
caseSensitive = state.caseSensitive,
)
}
Integration
The ViewModel takes ViewState as its first type parameter and accepts viewStateMapper in the constructor. The ViewModel maps State -> ViewState internally and exposes StateFlow<ViewState>:
@HiltViewModel
internal class ItemsViewModel @Inject constructor(
reducer: ItemsReducer,
effectHandler: ItemsEffectHandler,
viewStateMapper: ItemsViewStateMapper,
) : BaseStoreViewModel<ItemsViewState, ItemsState, ItemsCommand, ItemsSideEffect>(
initialState = ItemsState(),
reducer = reducer,
effectHandlers = listOf(effectHandler),
viewStateMapper = viewStateMapper,
initialSideEffects = listOf(ItemsSideEffect.LoadItems),
)
The Fragment renders ViewState directly â it does NOT inject the mapper:
@AndroidEntryPoint
internal class ItemsFragment : BaseStoreFragment<
FragmentItemsBinding,
ItemsViewState,
ItemsState,
ItemsCommand,
ItemsSideEffect,
ItemsViewModel,
>() {
override val viewModel by viewModels<ItemsViewModel>()
override fun render(state: ItemsViewState) {
binding.processList(state.items, state.itemsChanged)
binding.processSelection(state.selecting, state.selectedCount)
}
}
Key Rules
- State MUST NOT contain presentation models â use domain models or intermediate formatted models
- ViewState MUST NOT be the TEA State type â it is derived, not managed by the Store
- ViewStateMapper implements
ViewStateMapper<State, ViewState>interface fromcore/tea - ViewStateMapper MUST be a pure function (no side effects, no mutable state) â mapping runs on
viewStateMappingDispatcher(defaults toDispatchers.Main.immediate) - For expensive mapping operations, override
viewStateMappingDispatcherin the ViewModel constructor (e.g., passDispatchers.Default) - The expensive work (filtering, formatting, IO) stays in the EffectHandler on background dispatchers
- The cheap work (applying selection/expanded/textSize to pre-formatted items) happens in the mapper
- Selection, expansion, and similar UI-local state lives directly in State, managed by the Reducer as pure functions â no presentation-layer repositories or use cases
- When the Reducer modifies selection state that needs external sync, it emits a SideEffect carrying the data (e.g.,
SyncSelectedLines(selectedIds)) rather than relying on an internal repository
Type Alias for Convenience
typealias AuthStore = Store<AuthState, AuthCommand, AuthSideEffect>
typealias AuthStoreViewModel = BaseStoreViewModel<AuthViewState, AuthState, AuthCommand, AuthSideEffect>
2. Clean Architecture
Layer Structure
Each feature/module contains three layers:
- Presentation: UI components (Views/Composables) and ViewModels
- Domain: Use cases and repository interfaces
- Data: Data sources and repository implementations
Dependency Rules
Presentation ââââââ⺠Domain (api module)
â²
Data ââââââââââ
(impl module)
Critical Rules:
- Presentation layer MUST NOT know or access anything but its Domain layer (api module)
- Presentation layer MUST NOT directly access Data layer (impl module)
- Data layer depends on Domain layer (implements repository interfaces)
- Presentation only triggers actions via ViewModel
Naming Conventions
| Type | Name | Visibility |
|---|---|---|
| Interface | Normal name (e.g., LoginUseCase) |
public (in api module) |
| Implementation | Impl suffix (e.g., LoginUseCaseImpl) |
internal (in impl module) |
| Data Source Interface | Normal name (e.g., AuthRemoteDataSource) |
internal |
| Data Source Implementation | Impl suffix (e.g., AuthRemoteDataSourceImpl) |
internal |
| ViewModel | Feature name + ViewModel (e.g., AuthViewModel) |
internal |
| Repository Interface | Normal name (e.g., AuthRepository) |
public (in api module) |
| Repository Implementation | Impl suffix (e.g., AuthRepositoryImpl) |
internal (in impl module) |
| Reducer | Feature name + Reducer (e.g., AuthReducer) |
internal |
| EffectHandler | Feature name + role + EffectHandler (e.g., AuthNetworkEffectHandler) |
internal |
| State | Feature name + State (e.g., AuthState) |
internal |
| ViewState | Feature name + ViewState (e.g., AuthViewState) |
internal |
| ViewStateMapper | Feature name + ViewStateMapper (e.g., AuthViewStateMapper) |
internal |
Exception for Reducers and Effect Handlers:
Reducers and Effect Handlers do NOT use the Impl suffix because they already include the feature name in their title (e.g., AuthReducer, AuthNetworkEffectHandler). This makes the naming more concise while still being clear.
Domain Layer
Use Cases
Critical Rules:
- Use cases MUST use
invokeoperator function (allowsuseCase()syntax) - Use case methods MUST NOT throw – return
Result<T>type instead for failable operations - Use cases catch repository exceptions and convert them to
Result
// Interface - in api module, public
interface LoginUseCase {
suspend operator fun invoke(email: String, password: String): Result<User>
}
// Implementation - in impl module, internal
internal class LoginUseCaseImpl @Inject constructor(
private val authRepository: AuthRepository,
) : LoginUseCase {
override suspend fun invoke(email: String, password: String): Result<User> {
return runCatching {
authRepository.login(email, password)
}
}
}
// Non-failable use case example
interface MarkOnboardingCompletedUseCase {
suspend operator fun invoke()
}
internal class MarkOnboardingCompletedUseCaseImpl @Inject constructor(
private val onboardingRepository: OnboardingRepository,
) : MarkOnboardingCompletedUseCase {
override suspend fun invoke() {
onboardingRepository.markOnboardingCompleted()
}
}
Repository Interfaces
Critical Rules:
- Repository methods MUST use
throws(suspend functions that can throw) for failable operations, notResult<*> - Use
Flow<T>for reactive properties
// In api module
interface AuthRepository {
suspend fun login(email: String, password: String): User
suspend fun logout()
val isAuthenticated: Flow<Boolean>
}
Domain Models
// In api module
data class User(
val id: String,
val email: String,
val name: String,
)
Data Layer
Data Sources
Data sources operate on data models (DTOs). Data source interfaces are defined in the Data layer and are internal.
Critical Rules:
- Data source interfaces MUST be defined in the impl module, NOT in api
- Data source interfaces MUST be
internalvisibility - Data sources are ONLY accessible within the impl module
- Data sources MUST NOT be accessed from Domain or Presentation layers
// DTOs - internal, in impl module
internal data class UserDTO(
val id: String,
val email: String,
val name: String,
)
internal data class LoginRequestDTO(
val email: String,
val password: String,
)
// Remote Data Source Interface - INTERNAL, defined in impl module
internal interface AuthRemoteDataSource {
suspend fun login(request: LoginRequestDTO): UserDTO
suspend fun logout()
}
// Implementation - internal
internal class AuthRemoteDataSourceImpl @Inject constructor(
private val api: AuthApi,
) : AuthRemoteDataSource {
override suspend fun login(request: LoginRequestDTO): UserDTO {
return api.login(request)
}
override suspend fun logout() {
api.logout()
}
}
// Local Data Source Interface - INTERNAL
internal interface AuthLocalDataSource {
suspend fun saveUser(user: UserDTO)
suspend fun getUser(): UserDTO?
suspend fun clearUser()
val isAuthenticated: Flow<Boolean>
}
// Implementation - internal
internal class AuthLocalDataSourceImpl @Inject constructor(
private val dataStore: DataStore<Preferences>,
) : AuthLocalDataSource {
// Implementation...
}
Repository Implementation
Repositories combine data sources and map DTOs to domain models.
// Implementation - in impl module, internal
internal class AuthRepositoryImpl @Inject constructor(
private val remoteDataSource: AuthRemoteDataSource,
private val localDataSource: AuthLocalDataSource,
) : AuthRepository {
override suspend fun login(email: String, password: String): User {
val dto = remoteDataSource.login(
LoginRequestDTO(email = email, password = password)
)
localDataSource.saveUser(dto)
return dto.toDomain()
}
override suspend fun logout() {
runCatching { remoteDataSource.logout() }
localDataSource.clearUser()
}
override val isAuthenticated: Flow<Boolean>
get() = localDataSource.isAuthenticated
}
// Mapping extension - in impl module
internal fun UserDTO.toDomain(): User = User(
id = id,
email = email,
name = name,
)
3. Passive/Container View Pattern
Rules
- NO views can handle business logic state – they only display data provided to them
- Views accept lambdas to trigger events
- Views must be idempotent (same input = same output)
- Only Container screens (Activity/Fragment/Composable with ViewModel) can:
- Hold and observe a ViewModel
- Handle navigation
- Register lifecycle callbacks
- Containers are independent – they must not know about each other
- ViewModel lifecycle is bound to Container lifecycle
- Containers MUST NOT build ViewModels manually – they use Hilt injection
- NO callbacks between containers – use reactive data observation instead
Container Communication
// WRONG: Using callbacks between containers
class ParentFragment : BaseFragment<...>() {
fun showChild() {
ChildFragment(onItemSelected = { item ->
viewModel.send(AuthCommand.ItemSelected(item)) // WRONG: callback
})
}
}
// CORRECT: Child updates shared data, parent observes changes
class ParentFragment : BaseFragment<...>() {
// Parent's effect handler observes shared data changes via use case
// Child updates shared data through use case
// Parent automatically receives the update via its observation
}
Base Fragment Classes
Base classes abstract away Flow collection boilerplate. Feature fragments only need to implement render() and handleSideEffect(). There are three base fragment variants in core/tea/android:
- BaseStoreFragment â for regular fragments with ViewBinding
- BaseStoreBottomSheetFragment â for bottom sheet dialog fragments with ViewBinding
- BaseStorePreferenceFragment â for preference fragments (no ViewBinding)
All three follow the same type parameter pattern and API.
// In core/tea/android module - Base Fragment for TEA architecture
abstract class BaseStoreFragment<
VB : ViewBinding,
ViewState,
State,
Command,
SideEffect,
VM : BaseStoreViewModel<ViewState, State, Command, SideEffect>,
> : Fragment() {
private var _binding: VB? = null
protected val binding: VB get() = _binding!!
protected abstract val viewModel: VM
/**
* Create the ViewBinding for this fragment.
*/
abstract fun inflateBinding(inflater: LayoutInflater, container: ViewGroup?): VB
/**
* Render state to UI. Called on every state change.
* Must be idempotent - same state = same UI.
* Receives ViewState (already mapped from domain State by the ViewModel).
*/
abstract fun render(state: ViewState)
/**
* Handle side effects (navigation, snackbars, etc.)
* Called for ALL side effects - ignore those not relevant to UI.
*/
abstract fun handleSideEffect(sideEffect: SideEffect)
/**
* Called after view is created but before state collection starts.
* Override to set up views, click listeners, etc.
* The receiver is the ViewBinding - no need to prefix with `binding.`.
*/
protected open fun VB.onViewCreated(view: View, savedInstanceState: Bundle?) = Unit
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View {
_binding = inflateBinding(inflater, container)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.onViewCreated(view, savedInstanceState)
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
launch { viewModel.state.collect { state -> render(state) } }
launch { viewModel.sideEffects.collect { effect -> handleSideEffect(effect) } }
}
}
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
/**
* Convenience method to send commands to ViewModel
*/
protected fun send(command: Command) {
viewModel.send(command)
}
}
BaseStoreBottomSheetFragment
Same API as BaseStoreFragment but extends BottomSheetDialogFragment:
// In core/tea/android module
abstract class BaseStoreBottomSheetFragment<
VB : ViewBinding,
ViewState,
State,
Command,
SideEffect,
VM : BaseStoreViewModel<ViewState, State, Command, SideEffect>,
> : BottomSheetDialogFragment() {
// Same API: inflateBinding, render, handleSideEffect, VB.onViewCreated, send
}
BaseStorePreferenceFragment
For preference screens. No ViewBinding â uses PreferenceFragmentCompat’s built-in preference XML inflation.
// In core/tea/android module
abstract class BaseStorePreferenceFragment<
ViewState,
State,
Command,
SideEffect,
VM : BaseStoreViewModel<ViewState, State, Command, SideEffect>,
> : PreferenceFragmentCompat() {
protected abstract val viewModel: VM
abstract fun render(state: ViewState)
abstract fun handleSideEffect(sideEffect: SideEffect)
// send() convenience method available
}
Key differences from the old API:
- 6 type parameters (added
ViewStateas second):<VB, ViewState, State, Command, SideEffect, VM> render()receives ViewState, not State â the mapping is done in the ViewModelinflateBinding()replacescreateBinding()VB.onViewCreated()is an extension function on the binding â override it instead of overridingonViewCreated()directly. The binding is the receiver so you can access views directly withoutbinding.prefix
Feature Fragment Example
// Container Fragment - extends BaseStoreFragment with 6 type params
@AndroidEntryPoint
internal class AuthFragment :
BaseStoreFragment<
FragmentAuthBinding,
AuthViewState,
AuthState,
AuthCommand,
AuthSideEffect,
AuthViewModel,
>() {
override val viewModel by viewModels<AuthViewModel>()
override fun inflateBinding(
inflater: LayoutInflater,
container: ViewGroup?,
) = FragmentAuthBinding.inflate(inflater, container, false)
// Setup event listeners in VB.onViewCreated - binding is the receiver
override fun FragmentAuthBinding.onViewCreated(view: View, savedInstanceState: Bundle?) {
loginView.onEmailChanged = { send(AuthCommand.EmailChanged(it)) }
loginView.onPasswordChanged = { send(AuthCommand.PasswordChanged(it)) }
loginView.onLoginClick = { send(AuthCommand.LoginTapped) }
}
override fun render(state: AuthViewState) {
// Pure rendering - same ViewState = same UI
binding.loginView.bind(state)
}
override fun handleSideEffect(sideEffect: AuthSideEffect) {
// Handle UI-related side effects, ignore business logic ones
when (sideEffect) {
is AuthSideEffect.NavigateToHome -> {
findNavController().navigate(AuthFragmentDirections.actionAuthToHome())
}
is AuthSideEffect.ShowError -> {
Snackbar.make(binding.root, sideEffect.message, Snackbar.LENGTH_SHORT).show()
}
// Business logic side effects - handled by EffectHandlers, ignore here
else -> Unit
}
}
}
Feature BottomSheet Fragment Example
@AndroidEntryPoint
internal class SearchLogsBottomSheetFragment :
BaseStoreBottomSheetFragment<
SheetSearchBinding,
SearchLogsViewState,
SearchLogsState,
SearchLogsCommand,
SearchLogsSideEffect,
SearchLogsViewModel,
>() {
override val viewModel by viewModels<SearchLogsViewModel>()
override fun inflateBinding(inflater: LayoutInflater, container: ViewGroup?) =
SheetSearchBinding.inflate(inflater, container, false)
override fun SheetSearchBinding.onViewCreated(view: View, savedInstanceState: Bundle?) {
// Setup click listeners using the binding receiver
searchButton.setOnClickListener { send(SearchLogsCommand.UpdateQuery(queryText.text?.toString())) }
}
override fun render(state: SearchLogsViewState) {
binding.queryText.setText(state.query)
binding.caseSensitiveCheckbox.isChecked = state.caseSensitive
}
override fun handleSideEffect(sideEffect: SearchLogsSideEffect) {
when (sideEffect) {
is SearchLogsSideEffect.Dismiss -> dismiss()
else -> Unit
}
}
}
Views: Passive View with ViewBinding
// Passive View - only displays data via bind method, triggers events via lambdas
class LoginView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
) : FrameLayout(context, attrs) {
private val binding = ViewLoginBinding.inflate(LayoutInflater.from(context), this)
var onEmailChanged: ((String) -> Unit)? = null
var onPasswordChanged: ((String) -> Unit)? = null
var onLoginClick: (() -> Unit)? = null
init {
binding.emailEditText.doAfterTextChanged { onEmailChanged?.invoke(it.toString()) }
binding.passwordEditText.doAfterTextChanged { onPasswordChanged?.invoke(it.toString()) }
binding.loginButton.setOnClickListener { onLoginClick?.invoke() }
}
fun bind(state: AuthState) {
binding.emailEditText.setTextIfDifferent(state.email)
binding.passwordEditText.setTextIfDifferent(state.password)
binding.loginButton.isEnabled = !state.isLoading
binding.progressBar.isVisible = state.isLoading
binding.errorText.text = state.error
binding.errorText.isVisible = state.error != null
}
}
// Helper extension to prevent cursor jumping
private fun EditText.setTextIfDifferent(text: String) {
if (this.text.toString() != text) {
this.setText(text)
}
}
Compose: Passive Composable Example
// CORRECT: Passive composable - only displays data, triggers events via lambdas
@Composable
fun LoginContent(
email: String,
password: String,
isLoading: Boolean,
error: String?,
onEmailChanged: (String) -> Unit,
onPasswordChanged: (String) -> Unit,
onLoginClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
TextField(
value = email,
onValueChange = onEmailChanged,
label = { Text("Email") },
)
TextField(
value = password,
onValueChange = onPasswordChanged,
label = { Text("Password") },
visualTransformation = PasswordVisualTransformation(),
)
error?.let {
Text(
text = it,
color = MaterialTheme.colorScheme.error,
)
}
Button(
onClick = onLoginClick,
enabled = !isLoading,
) {
if (isLoading) {
CircularProgressIndicator(modifier = Modifier.size(24.dp))
} else {
Text("Login")
}
}
}
}
// WRONG: Composable handling its own state
@Composable
fun LoginContentWrong() {
var email by remember { mutableStateOf("") } // WRONG: Composable handling state
var password by remember { mutableStateOf("") } // WRONG: Composable handling state
var isLoading by remember { mutableStateOf(false) } // WRONG: Composable handling state
// ...
}
Compose: Container Screen Example
// Container - the only composable that can hold ViewModel and handle lifecycle
// state is already ViewState (mapped by the ViewModel)
@Composable
fun AuthScreen(
viewModel: AuthViewModel = hiltViewModel(),
onNavigateToHome: () -> Unit,
) {
val state by viewModel.state.collectAsStateWithLifecycle()
LaunchedEffect(viewModel) {
viewModel.sideEffects.collect { sideEffect ->
// Handle UI-related side effects, ignore business logic ones
when (sideEffect) {
is AuthSideEffect.NavigateToHome -> onNavigateToHome()
is AuthSideEffect.ShowError -> { /* show snackbar */ }
// Business logic side effects - handled by EffectHandlers, ignore here
else -> Unit
}
}
}
LoginContent(
email = state.email,
password = state.password,
isLoading = state.isLoading,
error = state.error,
onEmailChanged = { viewModel.send(AuthCommand.EmailChanged(it)) },
onPasswordChanged = { viewModel.send(AuthCommand.PasswordChanged(it)) },
onLoginClick = { viewModel.send(AuthCommand.LoginTapped) },
)
}
Nested Passive Views
// Parent passive view
@Composable
fun ProfileContent(
user: User,
settings: Settings,
onEditTapped: () -> Unit,
onSettingChanged: (Setting) -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier) {
// Child passive view - receives data, passes lambdas
ProfileHeaderContent(
name = user.name,
email = user.email,
onEditTapped = onEditTapped,
)
// Another child passive view
SettingsListContent(
settings = settings,
onSettingChanged = onSettingChanged,
)
}
}
// Child passive view
@Composable
fun ProfileHeaderContent(
name: String,
email: String,
onEditTapped: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(modifier = modifier) {
Column(modifier = Modifier.weight(1f)) {
Text(text = name, style = MaterialTheme.typography.headlineSmall)
Text(text = email, style = MaterialTheme.typography.bodyMedium)
}
IconButton(onClick = onEditTapped) {
Icon(Icons.Default.Edit, contentDescription = "Edit")
}
}
}
4. Reactive Data Flow
Overview
Data drives the application reactively. Use StateFlow and SharedFlow from kotlinx.coroutines to expose reactive state.
Repository Reactive Streams
// Repository interfaces with Flow - in api module
interface AuthRepository {
val isAuthenticated: Flow<Boolean>
suspend fun login(email: String, password: String): User
suspend fun logout()
}
interface OnboardingRepository {
val wasOnboardingCompleted: Flow<Boolean>
suspend fun markOnboardingCompleted()
}
interface SettingsRepository {
val selectedServer: Flow<Server?>
suspend fun selectServer(server: Server)
}
StateFlow Implementation in Data Layer
Critical Rules:
- Use
MutableStateFlowprivately in data sources - Expose as
Flow(read-only) - Use
StateFlowfor state that needs an initial value
// Implementation - in impl module, internal
internal class AuthLocalDataSourceImpl @Inject constructor(
private val dataStore: DataStore<Preferences>,
) : AuthLocalDataSource {
private val _isAuthenticated = MutableStateFlow(false)
override val isAuthenticated: Flow<Boolean> = _isAuthenticated.asStateFlow()
init {
// Initialize
val hasToken = getAccessToken() != null
_isAuthenticated.value = hasToken
}
override suspend fun saveTokens(tokens: AuthTokens) {
// ... save tokens ...
_isAuthenticated.value = true
}
override suspend fun clearTokens() {
// ... clear tokens ...
_isAuthenticated.value = false
}
}
Combining Flows
Use combine from kotlinx.coroutines to combine multiple flows.
// Use case that combines multiple flows
interface ObserveAppStateUseCase {
operator fun invoke(): Flow<AppScreen>
}
internal class ObserveAppStateUseCaseImpl @Inject constructor(
private val authRepository: AuthRepository,
private val onboardingRepository: OnboardingRepository,
) : ObserveAppStateUseCase {
override fun invoke(): Flow<AppScreen> = combine(
onboardingRepository.wasOnboardingCompleted,
authRepository.isAuthenticated,
) { isOnboardingCompleted, isAuthenticated ->
when {
!isOnboardingCompleted -> AppScreen.Onboarding
!isAuthenticated -> AppScreen.Auth
else -> AppScreen.Main
}
}
}
// Usage in ViewModel
@HiltViewModel
class ContentViewModel @Inject constructor(
private val observeAppStateUseCase: ObserveAppStateUseCase,
) : ViewModel() {
val appScreen: StateFlow<AppScreen> = observeAppStateUseCase()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = AppScreen.Loading,
)
}
5. Modularization
Overview
The project follows a modular architecture with two types of modules: core and feature.
Module Types
Core Modules
- Purpose: Reusable components across ANY application
- Examples: Base classes, networking, persistence, common UI components
- Characteristics:
- Generic, not app-specific
- Can be extracted as a library
- Examples:
BaseFragment,BaseViewModel, networking utilities
Feature Modules
- Purpose: App-specific functionality
- Examples: Authentication, profile, settings, specific domain models
- Characteristics:
- Contains business logic specific to this app
- Domain-specific models and use cases
Module Structure
Each module can consist of up to 3 Gradle modules:
feature/auth/
âââ api/ # MANDATORY - Interfaces and domain models
â âââ build.gradle.kts # Uses: logfox.kotlin.jvm (preferably) or logfox.android.library
âââ impl/ # MANDATORY - Implementations and DI
â âââ build.gradle.kts # Uses: logfox.kotlin.jvm (preferably) or logfox.android.feature + depends on api
âââ presentation/ # OPTIONAL - UI layer (only for features with UI)
âââ build.gradle.kts # Uses: logfox.android.feature.compose + depends on api ONLY
api module (MANDATORY)
- Contains interfaces and domain models
- Pure Kotlin when possible (
logfox.kotlin.jvm) - Use
logfox.android.libraryonly when Android-specific types needed - NO implementations, NO DI annotations
// feature/auth/api/src/main/kotlin/com/f0x1d/logfox/feature/auth/api/AuthRepository.kt
interface AuthRepository {
suspend fun login(email: String, password: String): User
val isAuthenticated: Flow<Boolean>
}
// feature/auth/api/src/main/kotlin/com/f0x1d/logfox/feature/auth/api/LoginUseCase.kt
interface LoginUseCase {
suspend operator fun invoke(email: String, password: String): Result<User>
}
// feature/auth/api/src/main/kotlin/com/f0x1d/logfox/feature/auth/api/User.kt
data class User(
val id: String,
val email: String,
val name: String,
)
impl module (MANDATORY)
- Contains implementations of api interfaces
- Contains data sources, DTOs, mappers
- Contains Hilt DI module
- Depends on api module
- Pure Kotlin when possible, Android when needed
// feature/auth/impl/src/main/kotlin/com/f0x1d/logfox/feature/auth/impl/AuthRepositoryImpl.kt
internal class AuthRepositoryImpl @Inject constructor(
private val remoteDataSource: AuthRemoteDataSource,
private val localDataSource: AuthLocalDataSource,
) : AuthRepository {
// Implementation...
}
// feature/auth/impl/src/main/kotlin/com/f0x1d/logfox/feature/auth/impl/di/AuthModule.kt
@Module
@InstallIn(SingletonComponent::class)
internal interface AuthModule {
@Binds
fun bindAuthRepository(impl: AuthRepositoryImpl): AuthRepository
@Binds
fun bindLoginUseCase(impl: LoginUseCaseImpl): LoginUseCase
}
presentation module (OPTIONAL)
- Contains UI components (Views/Composables) and ViewModels
- Contains TEA components: State, ViewState, ViewStateMapper, Command, SideEffect, Reducer, EffectHandler
- Depends ONLY on api module – NEVER on impl
- Android library module (required for UI components)
- Uses
logfox.android.feature.composefor Compose UI
// feature/auth/presentation/src/main/kotlin/com/f0x1d/logfox/feature/auth/presentation/AuthViewModel.kt
@HiltViewModel
internal class AuthViewModel @Inject constructor(
reducer: AuthReducer,
effectHandler: AuthEffectHandler,
viewStateMapper: AuthViewStateMapper,
) : BaseStoreViewModel<AuthViewState, AuthState, AuthCommand, AuthSideEffect>(
initialState = AuthState(),
reducer = reducer,
effectHandlers = listOf(effectHandler),
viewStateMapper = viewStateMapper,
)
// feature/auth/presentation/src/main/kotlin/com/f0x1d/logfox/feature/auth/presentation/AuthScreen.kt
@Composable
fun AuthScreen(
viewModel: AuthViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle() // Already ViewState
// Implementation...
}
Simplified Modules
Sometimes modules don’t need the full 3-module structure:
Core utility modules (no interfaces to expose)
core/tea/
âââ base/
â âââ build.gradle.kts # Pure Kotlin JVM - Store, Reducer, ReduceResult, EffectHandler, ViewStateMapper
âââ android/
âââ build.gradle.kts # Android - BaseStoreViewModel, BaseStoreFragment, BaseStoreBottomSheetFragment, BaseStorePreferenceFragment
core/ui/
âââ build.gradle.kts # Contains BaseActivity, theme
core/common/
âââ build.gradle.kts # Contains common extensions, utilities
Common data models used everywhere
feature/common/
âââ build.gradle.kts # Contains common data classes, no api/impl split needed
Dependency Rules Between Modules
:app
(depends on all presentation and impl modules)
â
âââââââââââââââââ¼ââââââââââââââââ
â¼ â¼ â¼
:feature: :feature: :core:
auth: profile: network:
presentation presentation impl
â â â
â¼ â¼ â¼
:feature: :feature: :core:
auth:api profile:api network:api
Critical Rules:
presentationmodules depend ONLY onapimodulesimplmodules depend on theirapimoduleimplmodules can depend on other modules’apimodules:appmodule depends on allpresentationandimplmodules- NEVER depend on another module’s
implmodule (except:app)
build.gradle.kts Examples
// feature/auth/api/build.gradle.kts
plugins {
alias(libs.plugins.logfox.kotlin.jvm)
}
dependencies {
api(libs.kotlinx.coroutines.core) // For Flow
}
// feature/auth/impl/build.gradle.kts
plugins {
alias(libs.plugins.logfox.android.feature)
}
dependencies {
api(projects.feature.auth.api)
implementation(projects.core.network.api)
implementation(projects.core.persistence.api)
}
// feature/auth/presentation/build.gradle.kts
plugins {
alias(libs.plugins.logfox.android.feature.compose)
}
dependencies {
implementation(projects.feature.auth.api) // ONLY api, never impl
implementation(projects.core.ui.api)
}
6. Dependency Injection (Hilt)
Overview
Use Hilt for dependency injection. Each feature’s impl module contains its DI module.
Critical Rules:
- Hilt module return types MUST be interfaces, NEVER implementations
- Implementations (
*Impl) must NEVER be used directly outside of impl modules - Use
@Bindsfor binding interface to implementation - Use
@Providesfor providing instances that require configuration
Module Structure
// In feature/auth/impl module
@Module
@InstallIn(SingletonComponent::class)
internal interface AuthBindsModule {
@Binds
fun bindAuthRepository(impl: AuthRepositoryImpl): AuthRepository
@Binds
fun bindLoginUseCase(impl: LoginUseCaseImpl): LoginUseCase
@Binds
fun bindLogoutUseCase(impl: LogoutUseCaseImpl): LogoutUseCase
}
@Module
@InstallIn(SingletonComponent::class)
internal object AuthProvidesModule {
@Provides
@Singleton
fun provideAuthApi(retrofit: Retrofit): AuthApi {
return retrofit.create(AuthApi::class.java)
}
}
Data Source Binding
@Module
@InstallIn(SingletonComponent::class)
internal interface AuthDataSourceModule {
@Binds
fun bindAuthRemoteDataSource(impl: AuthRemoteDataSourceImpl): AuthRemoteDataSource
@Binds
@Singleton // CRITICAL: shared state requires singleton
fun bindAuthLocalDataSource(impl: AuthLocalDataSourceImpl): AuthLocalDataSource
}
TEA Components Binding
Note: Reducers, EffectHandlers, and ViewStateMappers are @Inject-constructed and used directly in the ViewModel constructor â they do NOT need DI module bindings in most cases. Hilt can construct them directly.
DI module bindings are only needed when:
- You need to provide a
List<EffectHandler<...>>for multiple effect handlers - You want to bind to the generic interface type (e.g.,
Reducer<State, Command, SideEffect>)
// For multiple effect handlers, provide as List
@Module
@InstallIn(ViewModelComponent::class)
internal object AuthEffectHandlersModule {
@Provides
fun provideEffectHandlers(
networkHandler: AuthNetworkEffectHandler,
persistenceHandler: AuthPersistenceEffectHandler,
): List<EffectHandler<AuthSideEffect, AuthCommand>> = listOf(
networkHandler,
persistenceHandler,
)
}
In practice, most ViewModels inject the concrete Reducer, EffectHandler, and ViewStateMapper types directly:
@HiltViewModel
internal class AuthViewModel @Inject constructor(
reducer: AuthReducer, // concrete type, no binding needed
effectHandler: AuthEffectHandler, // concrete type, no binding needed
viewStateMapper: AuthViewStateMapper, // concrete type, no binding needed
) : BaseStoreViewModel<AuthViewState, AuthState, AuthCommand, AuthSideEffect>(...)
Scopes and Lifecycle
@Module
@InstallIn(SingletonComponent::class)
internal interface AuthModule {
// Singleton - lives for app lifetime (shared state)
@Binds
@Singleton
fun bindTokenStore(impl: TokenStoreImpl): TokenStore
// Unscoped - new instance each time (stateless)
@Binds
fun bindLoginUseCase(impl: LoginUseCaseImpl): LoginUseCase
}
Singleton Data Sources
Critical Rule: Data sources that maintain shared state (e.g., StateFlow, in-memory caches) and are used by multiple repositories MUST be singletons.
@Module
@InstallIn(SingletonComponent::class)
internal interface AuthDataSourceModule {
// SINGLETON: Local data source with shared StateFlow
@Binds
@Singleton
fun bindAuthLocalDataSource(impl: AuthLocalDataSourceImpl): AuthLocalDataSource
}
// Example: Data source with shared state
internal class AuthLocalDataSourceImpl @Inject constructor(
private val dataStore: DataStore<Preferences>,
) : AuthLocalDataSource {
private val _isAuthenticated = MutableStateFlow(false)
override val isAuthenticated: Flow<Boolean> = _isAuthenticated.asStateFlow()
override suspend fun saveToken(token: String) {
// All observers receive this update
_isAuthenticated.value = true
}
}
7. Navigation
Overview
Use Jetpack Navigation for navigation. Support both Fragment-based navigation (legacy) and Compose navigation.
Navigation with Fragments
// Use Safe Args for type-safe navigation
// Navigation graph defines destinations
// In Fragment
class AuthFragment : BaseFragment<FragmentAuthBinding>() {
private val viewModel: AuthViewModel by viewModels()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewLifecycleOwner.lifecycleScope.launch {
viewModel.effects.collect { effect ->
when (effect) {
is AuthEffect.NavigateToHome -> {
findNavController().navigate(AuthFragmentDirections.actionAuthToHome())
}
is AuthEffect.NavigateToRegister -> {
findNavController().navigate(AuthFragmentDirections.actionAuthToRegister())
}
}
}
}
}
}
SideEffect-based Navigation
Navigation should be triggered by SideEffects from Reducer, NOT directly from UI events.
// SideEffect includes navigation variants
sealed interface AuthSideEffect {
// Business logic
data class Login(val email: String, val password: String) : AuthSideEffect
data object Logout : AuthSideEffect
// UI/Navigation
data object NavigateToHome : AuthSideEffect
data object NavigateToRegister : AuthSideEffect
data class NavigateToForgotPassword(val email: String?) : AuthSideEffect
data class ShowError(val message: String) : AuthSideEffect
}
// Container handles navigation (Compose)
@Composable
fun AuthScreen(
viewModel: AuthViewModel = hiltViewModel(),
onNavigateToHome: () -> Unit,
onNavigateToRegister: () -> Unit,
) {
LaunchedEffect(viewModel) {
viewModel.sideEffects.collect { sideEffect ->
when (sideEffect) {
AuthSideEffect.NavigateToHome -> onNavigateToHome()
AuthSideEffect.NavigateToRegister -> onNavigateToRegister()
is AuthSideEffect.NavigateToForgotPassword -> { /* navigate */ }
is AuthSideEffect.ShowError -> { /* show snackbar */ }
else -> Unit // Business logic handled by EffectHandlers
}
}
}
// ...
}
// Container handles navigation (Fragment with BaseStoreFragment)
override fun handleSideEffect(sideEffect: AuthSideEffect) {
when (sideEffect) {
AuthSideEffect.NavigateToHome -> {
findNavController().navigate(AuthFragmentDirections.actionAuthToHome())
}
AuthSideEffect.NavigateToRegister -> {
findNavController().navigate(AuthFragmentDirections.actionAuthToRegister())
}
is AuthSideEffect.NavigateToForgotPassword -> {
findNavController().navigate(
AuthFragmentDirections.actionAuthToForgotPassword(sideEffect.email)
)
}
is AuthSideEffect.ShowError -> {
Snackbar.make(binding.root, sideEffect.message, Snackbar.LENGTH_SHORT).show()
}
else -> Unit // Business logic handled by EffectHandlers
}
}
8. Convention Plugins
Available Plugins
| Plugin ID | Description | Use Case |
|---|---|---|
logfox.kotlin.jvm |
Pure Kotlin JVM module | api modules without Android dependencies |
logfox.android.library |
Android library module | Android-specific api or standalone modules |
logfox.android.feature |
Feature module with Hilt | impl modules |
logfox.android.feature.compose |
Feature + Compose + Tests | presentation modules with Compose UI |
logfox.android.hilt |
Adds Hilt DI | Added automatically by feature plugins |
logfox.android.compose |
Adds Compose | Added automatically by compose feature plugin |
logfox.android.room |
Adds Room database | Modules using Room |
logfox.android.parcelize |
Adds Parcelize | Modules with Parcelable classes |
Plugin Hierarchy
logfox.android.feature.compose
âââ logfox.android.feature
âââ logfox.android.library
âââ logfox.android.hilt
âââ logfox.android.compose
Usage Examples
// Pure Kotlin api module
// feature/auth/api/build.gradle.kts
plugins {
alias(libs.plugins.logfox.kotlin.jvm)
}
// Android api module (when Android types needed)
// core/context/api/build.gradle.kts
plugins {
alias(libs.plugins.logfox.android.library)
}
// impl module
// feature/auth/impl/build.gradle.kts
plugins {
alias(libs.plugins.logfox.android.feature)
}
// presentation module with Compose
// feature/auth/presentation/build.gradle.kts
plugins {
alias(libs.plugins.logfox.android.feature.compose)
}
// Database module
// core/database/impl/build.gradle.kts
plugins {
alias(libs.plugins.logfox.android.feature)
alias(libs.plugins.logfox.android.room)
}
9. Project Structure
One Type Per File Rule
Critical Rule: Each file MUST contain exactly ONE type (class, interface, object, enum, or typealias), regardless of visibility.
Rules:
- One file = One type (class/interface/object/enum/data class/typealias)
- File name MUST match the type name (e.g.,
LoginUseCase.ktforinterface LoginUseCase) - Extensions of the same type are allowed in the same file
- Private helper extensions of OTHER types are allowed in the same file
- NO private/internal helper types in the same file – extract them to separate files
- Group related files into appropriate packages
Examples:
// CORRECT: One type per file
domain/usecase/
âââ LoginUseCase.kt // interface LoginUseCase
âââ LoginUseCaseImpl.kt // class LoginUseCaseImpl
âââ LogoutUseCase.kt // interface LogoutUseCase
âââ LogoutUseCaseImpl.kt // class LogoutUseCaseImpl
// WRONG: Multiple types in one file
domain/usecase/
âââ AuthUseCases.kt // Contains LoginUseCase, LogoutUseCase, etc.
Folder Organization
app/
âââ src/main/kotlin/com/f0x1d/logfox/
â âââ App.kt # Application class
â âââ MainActivity.kt # Main activity
â âââ navigation/
â âââ AppNavigation.kt # Root navigation
core/
âââ tea/
â âââ base/
â â âââ src/main/kotlin/com/f0x1d/logfox/core/tea/
â â âââ Store.kt # TEA Store implementation
â â âââ Reducer.kt # Reducer interface
â â âââ ReduceResult.kt # ReduceResult data class
â â âââ EffectHandler.kt # EffectHandler interface (extends Closeable)
â â âââ ViewStateMapper.kt # ViewStateMapper interface
â âââ android/
â âââ src/main/kotlin/com/f0x1d/logfox/core/tea/
â âââ BaseStoreViewModel.kt # Base ViewModel for TEA
â âââ BaseStoreFragment.kt # Base Fragment for TEA
â âââ BaseStoreBottomSheetFragment.kt # Base BottomSheet for TEA
â âââ BaseStorePreferenceFragment.kt # Base PreferenceFragment for TEA
âââ ui/
â âââ src/main/kotlin/com/f0x1d/logfox/core/ui/
â âââ BaseFragment.kt # Simple base Fragment
â âââ theme/
â âââ Theme.kt
â âââ Color.kt
âââ network/
â âââ api/
â â âââ src/main/kotlin/com/f0x1d/logfox/core/network/api/
â â âââ HttpClient.kt # Interface
â â âââ NetworkError.kt # Sealed class
â âââ impl/
â âââ src/main/kotlin/com/f0x1d/logfox/core/network/impl/
â âââ HttpClientImpl.kt # Implementation
â âââ di/
â âââ NetworkModule.kt # Hilt module
âââ persistence/
â âââ api/
â â âââ src/main/kotlin/com/f0x1d/logfox/core/persistence/api/
â â âââ DataStoreClient.kt # Interface
â âââ impl/
â âââ src/main/kotlin/com/f0x1d/logfox/core/persistence/impl/
â âââ DataStoreClientImpl.kt
â âââ di/
â âââ PersistenceModule.kt
âââ common/
âââ src/main/kotlin/com/f0x1d/logfox/core/common/
âââ extensions/
âââ FlowExtensions.kt
âââ ContextExtensions.kt
feature/
âââ auth/
â âââ api/
â â âââ src/main/kotlin/com/f0x1d/logfox/feature/auth/api/
â â âââ AuthRepository.kt
â â âââ LoginUseCase.kt
â â âââ LogoutUseCase.kt
â â âââ User.kt
â âââ impl/
â â âââ src/main/kotlin/com/f0x1d/logfox/feature/auth/impl/
â â âââ AuthRepositoryImpl.kt
â â âââ LoginUseCaseImpl.kt
â â âââ LogoutUseCaseImpl.kt
â â âââ datasource/
â â â âââ AuthRemoteDataSource.kt
â â â âââ AuthRemoteDataSourceImpl.kt
â â â âââ AuthLocalDataSource.kt
â â â âââ AuthLocalDataSourceImpl.kt
â â âââ dto/
â â â âââ UserDTO.kt
â â â âââ LoginRequestDTO.kt
â â âââ mapper/
â â â âââ UserMapper.kt
â â âââ di/
â â âââ AuthModule.kt
â âââ presentation/
â âââ src/main/kotlin/com/f0x1d/logfox/feature/auth/presentation/
â âââ AuthViewModel.kt # Feature ViewModel
â âââ AuthState.kt # Internal domain State
â âââ AuthViewState.kt # Presentation-ready ViewState
â âââ AuthViewStateMapper.kt # State -> ViewState mapper (implements ViewStateMapper)
â âââ AuthCommand.kt # User actions / feedback commands
â âââ AuthSideEffect.kt # Side effects (business + UI)
â âââ AuthReducer.kt # Pure reducer function
â âââ AuthNetworkEffectHandler.kt # Network side effect handler
â âââ AuthPersistenceEffectHandler.kt # Persistence side effect handler
â âââ AuthScreen.kt # Container composable
â âââ AuthFragment.kt # Container fragment (if using Views)
â âââ component/
â âââ LoginContent.kt # Passive composable
â âââ RegisterContent.kt
âââ profile/
â âââ api/
â âââ impl/
â âââ presentation/
âââ settings/
âââ api/
âââ impl/
âââ presentation/
Package Naming
Critical Rule: Every api, impl, and presentation Gradle module MUST include its module type as a package segment. The package pattern is:
com.f0x1d.logfox.<module-type>.<module-name>.<api|impl|presentation>[.subpackage]
Where:
<module-type>isfeatureorcore<module-name>is the feature/core name (e.g.,auth,logging,preferences)<api|impl|presentation>corresponds to the Gradle sub-module
Examples:
| Gradle module | Package root |
|---|---|
:app |
com.f0x1d.logfox |
:feature:auth:api |
com.f0x1d.logfox.feature.auth.api |
:feature:auth:impl |
com.f0x1d.logfox.feature.auth.impl |
:feature:auth:presentation |
com.f0x1d.logfox.feature.auth.presentation |
:core:preferences:api |
com.f0x1d.logfox.core.preferences.api |
:core:preferences:impl |
com.f0x1d.logfox.core.preferences.impl |
:core:ui:base |
com.f0x1d.logfox.core.ui (standalone, no api/impl split) |
Sub-packages within each module follow naturally:
com.f0x1d.logfox.feature.auth.api.data # repository interfaces
com.f0x1d.logfox.feature.auth.api.domain # use case interfaces
com.f0x1d.logfox.feature.auth.api.model # domain models
com.f0x1d.logfox.feature.auth.impl.data # repository implementations, data sources
com.f0x1d.logfox.feature.auth.impl.di # Hilt modules
com.f0x1d.logfox.feature.auth.impl.domain # use case implementations
com.f0x1d.logfox.feature.auth.presentation.ui # fragments, screens
Why this matters:
- Prevents package collisions between api and impl modules (e.g., both having a
datasub-package) - Makes it immediately obvious from an import which module a class belongs to
- The
android.namespaceinbuild.gradle.ktsMUST match the package root (e.g.,com.f0x1d.logfox.feature.auth.api)
Summary of Critical Rules
- TEA Pattern: State is immutable, Commands trigger state changes via Reducer, SideEffects handled by both EffectHandlers (business) and UI (navigation/toast)
- Reducer: Pure function, takes State + Command, returns new State + SideEffects. NO side effects allowed in reducer
- EffectHandler: Handles SideEffects asynchronously,
onCommandis suspend and useswithContext(Dispatchers.Main.immediate)to callStore.send(). ExtendsCloseablefor resource cleanup - Store.send(): MUST be called only from Main thread
- SideEffects: Serve dual purpose – business logic (handled by EffectHandlers) and UI actions (handled by Fragment/Composable)
- ViewState is MANDATORY: Every feature has State (domain, managed by Reducer) and ViewState (presentation, derived by ViewStateMapper). ViewStateMapper implements
ViewStateMapper<State, ViewState>interface fromcore/tea - BaseStoreViewModel: 4 type params
<ViewState, State, Command, SideEffect>, maps State -> ViewState internally, exposesStateFlow<ViewState> - BaseStoreFragment: 6 type params
<VB, ViewState, State, Command, SideEffect, VM>, renders ViewState, usesinflateBinding()andVB.onViewCreated() - Base Fragment Variants:
BaseStoreFragment,BaseStoreBottomSheetFragment,BaseStorePreferenceFragment– all incore/tea/android - core/tea module split:
core/tea/base/(pure Kotlin JVM – Store, Reducer, ReduceResult, EffectHandler, ViewStateMapper) andcore/tea/android/(Android – BaseStoreViewModel, BaseStoreFragment, etc.) - Use Cases: Must use
invokeoperator, returnResult<T>for failable operations - Repositories: Methods can throw, expose
Flowfor reactive data - Data Sources: Internal to impl module, never exposed outside
- Views: Passive, only display data and trigger events via lambdas
- Modularization: api/impl/presentation structure, presentation depends ONLY on api
- DI: Use Hilt
@Bindsfor interfaces, singleton for shared state. TEA components (Reducer, EffectHandler, ViewStateMapper) are@Inject-constructed and used directly – no DI binding needed unless providing a list - Navigation: SideEffect-based, handled in container components
- File Structure: One type per file, file name matches type name
- Convention Plugins: Use appropriate plugin for each module type
- Package Naming: Every api/impl/presentation module MUST include its module type as a package segment:
com.f0x1d.logfox.<feature|core>.<name>.<api|impl|presentation>. Theandroid.namespaceinbuild.gradle.ktsMUST match this package root