Skip to main content

Android Architecture

This document describes the Clean Architecture design of the Visla GPS Android application, covering layer responsibilities, dependency flow, package organization, and the reasoning behind key architectural decisions.

Architecture Overview​

The app follows Clean Architecture principles with 3 primary layers (UI, Domain, Data) and 2 supporting layers (Core for cross-cutting infrastructure, DI for dependency wiring). Each layer has a well-defined responsibility and communicates only through abstractions.

Dependency Flow​

The Domain layer has zero dependencies on Data or UI. Both the UI and Data layers depend on Domain. Core provides cross-cutting infrastructure used by Data and UI but never contains business logic.


Package Structure​

com.visla.vislagps/
β”œβ”€β”€ MainActivity.kt
β”œβ”€β”€ VislaGPSApp.kt # @HiltAndroidApp entry point
β”‚
β”œβ”€β”€ domain/ # Pure Kotlin β€” no Android dependencies
β”‚ β”œβ”€β”€ entities/ # Business models
β”‚ β”‚ β”œβ”€β”€ Device.kt # Device, DeviceWithPosition, DevicePermissions, DeviceStatus
β”‚ β”‚ β”œβ”€β”€ Position.kt # Position with GPS attributes
β”‚ β”‚ β”œβ”€β”€ Event.kt # Device events
β”‚ β”‚ β”œβ”€β”€ Geofence.kt # Geofence, GeofenceCreateData, GeofenceUpdateData
β”‚ β”‚ β”œβ”€β”€ User.kt # User profile
β”‚ β”‚ β”œβ”€β”€ Command.kt # Device commands
β”‚ β”‚ β”œβ”€β”€ Subscription.kt # Billing/subscription state
β”‚ β”‚ β”œβ”€β”€ Sharing.kt # Share invites and permissions
β”‚ β”‚ β”œβ”€β”€ NotificationEntities.kt # Notification history items
β”‚ β”‚ β”œβ”€β”€ NotificationNavigation.kt
β”‚ β”‚ β”œβ”€β”€ NotificationClickData.kt
β”‚ β”‚ β”œβ”€β”€ DeepLink.kt # Deep link data
β”‚ β”‚ β”œβ”€β”€ DeviceIconCategory.kt # Device icon categories and icons
β”‚ β”‚ └── LegalDocument.kt # Terms/privacy documents
β”‚ β”œβ”€β”€ repositories/ # Port interfaces (12 interfaces)
β”‚ β”‚ β”œβ”€β”€ AuthRepository.kt
β”‚ β”‚ β”œβ”€β”€ DeviceRepository.kt
β”‚ β”‚ β”œβ”€β”€ DeviceLifecycleRepository.kt
β”‚ β”‚ β”œβ”€β”€ PositionRepository.kt
β”‚ β”‚ β”œβ”€β”€ EventRepository.kt
β”‚ β”‚ β”œβ”€β”€ GeofenceRepository.kt
β”‚ β”‚ β”œβ”€β”€ SharingRepository.kt
β”‚ β”‚ β”œβ”€β”€ SubscriptionRepository.kt
β”‚ β”‚ β”œβ”€β”€ CommandRepository.kt
β”‚ β”‚ β”œβ”€β”€ NotificationRepository.kt
β”‚ β”‚ β”œβ”€β”€ RealTimeRepository.kt
β”‚ β”‚ └── BillingRepository.kt
β”‚ β”œβ”€β”€ usecases/ # Business logic, organized by feature
β”‚ β”‚ β”œβ”€β”€ auth/ # 17 use cases (login, register, 2FA, password, …)
β”‚ β”‚ β”œβ”€β”€ devices/ # 5 use cases + DevicesInteractor
β”‚ β”‚ β”œβ”€β”€ geofences/ # 5 use cases (CRUD + list)
β”‚ β”‚ β”œβ”€β”€ commands/ # 6 use cases (send, fetch, status)
β”‚ β”‚ β”œβ”€β”€ sharing/ # 7 use cases (share, revoke, invites)
β”‚ β”‚ β”œβ”€β”€ billing/ # License & subscription use cases
β”‚ β”‚ β”œβ”€β”€ events/ # Event queries
β”‚ β”‚ β”œβ”€β”€ positions/ # Position history
β”‚ β”‚ β”œβ”€β”€ notifications/ # Push, history, settings
β”‚ β”‚ β”œβ”€β”€ realtime/ # WebSocket connection management
β”‚ β”‚ └── deeplink/ # Deep link parsing & handling
β”‚ β”œβ”€β”€ errors/
β”‚ β”‚ └── DomainExceptions.kt # Sealed exception hierarchy
β”‚ β”œβ”€β”€ services/
β”‚ β”‚ └── PushTokenManager.kt # FCM token abstraction
β”‚ └── ValidationConstants.kt # Shared validation rules
β”‚
β”œβ”€β”€ data/ # Android/network dependencies
β”‚ β”œβ”€β”€ remote/
β”‚ β”‚ β”œβ”€β”€ api/ # Retrofit service interfaces (11 APIs)
β”‚ β”‚ └── dto/ # Data Transfer Objects (10 files)
β”‚ β”œβ”€β”€ repositories/ # Repository implementations (15 classes)
β”‚ β”œβ”€β”€ mappers/ # DTO ↔ Entity mappers (9 mappers)
β”‚ β”œβ”€β”€ network/
β”‚ β”‚ └── WebSocketManager.kt # OkHttp WebSocket with auto-reconnect
β”‚ β”œβ”€β”€ auth/
β”‚ β”‚ β”œβ”€β”€ GoogleAuthManager.kt
β”‚ β”‚ └── FacebookAuthManager.kt
β”‚ β”œβ”€β”€ billing/
β”‚ β”‚ β”œβ”€β”€ IBillingManager.kt
β”‚ β”‚ └── BillingManager.kt # Google Play Billing
β”‚ β”œβ”€β”€ models/
β”‚ β”‚ └── LegacyModels.kt
β”‚ └── MapProviderManager.kt # Google Maps / Mapbox switching
β”‚
β”œβ”€β”€ ui/ # Jetpack Compose UI
β”‚ β”œβ”€β”€ base/
β”‚ β”‚ β”œβ”€β”€ BaseIntent.kt # MVI intent marker interface
β”‚ β”‚ └── IntentViewModel.kt # Base ViewModel with intent dispatch
β”‚ β”œβ”€β”€ screens/ # Feature screens (Screen + ViewModel pairs)
β”‚ β”œβ”€β”€ viewmodels/ # Shared ViewModels (MapViewModel, etc.)
β”‚ β”œβ”€β”€ components/ # Reusable Compose components
β”‚ β”œβ”€β”€ navigation/
β”‚ β”‚ β”œβ”€β”€ MainNavigation.kt
β”‚ β”‚ β”œβ”€β”€ NavigationBar.kt
β”‚ β”‚ └── ProfilePreloadEntryPoint.kt
β”‚ β”œβ”€β”€ handlers/
β”‚ β”‚ β”œβ”€β”€ DeepLinkHandler.kt
β”‚ β”‚ └── PushNotificationHandler.kt
β”‚ β”œβ”€β”€ state/
β”‚ β”‚ └── NotificationState.kt
β”‚ β”œβ”€β”€ formatters/ # Display formatting utilities
β”‚ β”œβ”€β”€ maps/ # Map integration components
β”‚ β”œβ”€β”€ subscription/ # Subscription UI
β”‚ β”œβ”€β”€ theme/ # Material theme definitions
β”‚ └── utils/ # UI utility functions
β”‚
β”œβ”€β”€ core/ # Cross-cutting infrastructure
β”‚ β”œβ”€β”€ network/
β”‚ β”‚ β”œβ”€β”€ TokenManager.kt # JWT storage, refresh, migration
β”‚ β”‚ β”œβ”€β”€ EncryptedTokenDataStore.kt # Encrypted token persistence
β”‚ β”‚ β”œβ”€β”€ AuthInterceptor.kt # OkHttp auth header injection
β”‚ β”‚ β”œβ”€β”€ TokenAuthenticator.kt # Automatic 401 β†’ token refresh
β”‚ β”‚ β”œβ”€β”€ BaseUrlProvider.kt # Environment-aware base URL
β”‚ β”‚ β”œβ”€β”€ NetworkConstants.kt
β”‚ β”‚ β”œβ”€β”€ HttpStatusCode.kt
β”‚ β”‚ └── NullOnEmptyConverterFactory.kt
β”‚ β”œβ”€β”€ data/ # Observable data stores (source of truth)
β”‚ β”‚ β”œβ”€β”€ DeviceDataStore.kt
β”‚ β”‚ β”œβ”€β”€ GeofenceDataStore.kt
β”‚ β”‚ β”œβ”€β”€ SharingDataStore.kt
β”‚ β”‚ β”œβ”€β”€ InviteDataStore.kt
β”‚ β”‚ β”œβ”€β”€ NotificationDataStore.kt
β”‚ β”‚ β”œβ”€β”€ RealTimeDataBridge.kt # WebSocket β†’ DataStore sync
β”‚ β”‚ └── AppPreferencesDataStore.kt
β”‚ β”œβ”€β”€ cache/
β”‚ β”‚ └── MemoryCache.kt # TTL-based in-memory cache
β”‚ β”œβ”€β”€ config/
β”‚ β”‚ └── DeepLinkConfig.kt
β”‚ β”œβ”€β”€ initialization/
β”‚ β”‚ └── AppInitializer.kt # Logger, Facebook SDK setup
β”‚ β”œβ”€β”€ notification/
β”‚ β”‚ └── NotificationManager.kt # Android notification channels
β”‚ β”œβ”€β”€ permissions/
β”‚ β”‚ └── PermissionManager.kt # Runtime permission handling
β”‚ β”œβ”€β”€ service/
β”‚ β”‚ β”œβ”€β”€ FCMService.kt # Firebase Cloud Messaging receiver
β”‚ β”‚ └── FCMTokenManagerImpl.kt # PushTokenManager implementation
β”‚ β”œβ”€β”€ geometry/
β”‚ β”‚ β”œβ”€β”€ GeoConstants.kt
β”‚ β”‚ └── WktBuilder.kt # WKT geometry construction
β”‚ └── utils/
β”‚ β”œβ”€β”€ Logger.kt # Structured logging
β”‚ β”œβ”€β”€ LoggingConstants.kt
β”‚ β”œβ”€β”€ LocaleManager.kt
β”‚ β”œβ”€β”€ TimeConstants.kt
β”‚ └── StringExtensions.kt
β”‚
β”œβ”€β”€ di/ # Hilt dependency injection modules
β”‚ β”œβ”€β”€ AppModule.kt # LocaleManager
β”‚ β”œβ”€β”€ NetworkModule.kt # OkHttp, Retrofit, all API interfaces
β”‚ β”œβ”€β”€ RepositoryModule.kt # Interface β†’ Impl bindings (11 repos)
β”‚ β”œβ”€β”€ ServiceModule.kt # PushTokenManager β†’ FCMTokenManagerImpl
β”‚ β”œβ”€β”€ TokenManagerModule.kt # TokenManager, EncryptedTokenDataStore
β”‚ β”œβ”€β”€ BaseUrlModule.kt # Environment-specific base URL
β”‚ β”œβ”€β”€ BillingModule.kt # Billing (debug/release variants)
β”‚ └── AppPreferencesModule.kt # SharedPreferences / DataStore
β”‚
└── widget/ # Home screen widget (Glance)
β”œβ”€β”€ DeviceGridWidget.kt
β”œβ”€β”€ WidgetDataStore.kt
β”œβ”€β”€ WidgetDevice.kt
β”œβ”€β”€ WidgetGridBuilder.kt
└── WidgetSyncWorker.kt

Layer Details​

Domain Layer​

The domain layer is pure Kotlin with no Android framework dependencies. It defines the business rules of the application and serves as the stable core that other layers depend on.

Entities​

Domain entities are immutable data classes representing core business concepts:

data class Device(
val id: Int,
val name: String,
val uniqueId: String,
val status: DeviceStatus,
val disabled: Boolean,
val lastUpdate: Instant?,
val positionId: Int?,
val isOwner: Boolean,
val suspended: Boolean,
val isMuted: Boolean,
val permissions: DevicePermissions,
// ...
)

data class DeviceWithPosition(
val device: Device,
val position: Position?
)

Entities never contain serialization annotations or framework-specific code.

Repository Interfaces (Ports)​

Repository interfaces define the contracts for data access. The domain layer declares what data it needs without specifying how it's fetched:

interface DeviceRepository {
val devicesWithPositionsFlow: StateFlow<List<DeviceWithPosition>>

suspend fun getDevices(): List<Device>
suspend fun getDevice(id: Int): Device
suspend fun getDevicesWithPositions(): List<DeviceWithPosition>
suspend fun refreshDevices(): List<DeviceWithPosition>
suspend fun claimDevice(token: String): Device
suspend fun updateDevice(
id: Int, name: String? = null, icon: String? = null,
color: String? = null, category: String? = null
): Device
suspend fun unclaimDevice(id: Int)
suspend fun invalidateCache()
// ...
}

Reactive state is exposed via StateFlow for real-time data (positions, connection status), while one-shot operations use suspend functions. The RealTimeRepository exposes WebSocket events as Flows:

interface RealTimeRepository {
val isConnected: StateFlow<Boolean>
val positionUpdates: Flow<Position>
val deviceStatusUpdates: Flow<Pair<Int, String>>
val geofenceCreated: Flow<Geofence>
val shareInviteReceived: Flow<ShareInviteReceivedEvent>
// ...
fun connect()
fun disconnect()
}

Use Cases​

Use cases encapsulate a single business operation. They follow the operator invoke() convention so they read like function calls:

class ClaimDeviceUseCase @Inject constructor(
private val deviceRepository: DeviceRepository
) {
suspend operator fun invoke(token: String): Device {
val normalizedToken = token.trim().uppercase()

if (normalizedToken.isEmpty()) {
throw ValidationException("Claim token is required", "token")
}

val minLength = ValidationConstants.VERIFICATION_CODE_LENGTH
if (normalizedToken.length < minLength) {
throw InvalidClaimTokenException(
"Token must be at least $minLength characters"
)
}

return deviceRepository.claimDevice(normalizedToken)
}
}

Use cases handle input validation, normalization, and business rule enforcement before delegating to repositories. They throw typed DomainExceptions on failure.

Interactors are facades that group related use cases for ViewModels that need multiple operations:

class DevicesInteractor @Inject constructor(
private val fetchDevicesUseCase: FetchDevicesUseCase,
private val claimDeviceUseCase: ClaimDeviceUseCase,
private val unclaimDeviceUseCase: UnclaimDeviceUseCase,
private val deviceRepository: DeviceRepository
)

Domain Exceptions​

A sealed class hierarchy provides typed, context-rich error handling:

sealed class DomainException(message: String, cause: Throwable? = null)
: Exception(message, cause)

// Auth
class AuthenticationException(...) : DomainException(...)
class TwoFactorRequiredException(val tempToken: String, val type: String) : DomainException(...)
class EmailNotVerifiedException(val email: String) : DomainException(...)

// Device
class DeviceNotFoundException(val deviceId: Int?) : DomainException(...)
class DeviceAccessDeniedException(val deviceId: Int) : DomainException(...)

// Network
class NetworkException(...) : DomainException(...)
class ServerException(val statusCode: Int) : DomainException(...)

// Subscription
class DeviceLimitReachedException(val limit: Int, val current: Int) : DomainException(...)
// ... ~27 exception types total

Each exception carries contextual data (device IDs, email addresses, status codes) that the UI layer uses to display meaningful error messages.


Data Layer​

The data layer implements domain repository interfaces using Retrofit, OkHttp WebSockets, and local caching.

Retrofit API Interfaces​

Each domain area has a corresponding Retrofit interface:

interface DeviceApi {
@GET("api/devices/")
suspend fun getDevices(): List<DeviceDto>

@GET("api/devices/{id}")
suspend fun getDevice(@Path("id") id: Int): DeviceDto

@POST("api/devices/claim")
suspend fun claimDevice(@Body request: ClaimDeviceRequest): DeviceClaimResponse

@PUT("api/devices/{id}")
suspend fun updateDevice(@Path("id") id: Int, @Body request: UpdateDeviceRequest): DeviceDto
// ...
}

DTOs (Data Transfer Objects)​

DTOs mirror the API contract and are kept separate from domain entities:

data class DeviceDto(
val id: Int,
val name: String,
val uniqueId: String? = null,
val status: String? = null, // String, not enum β€” API contract
val permissions: DevicePermissionsDto?,
// ...
)

data class UpdateDeviceRequest(
val name: String? = null,
val icon: String? = null,
val color: String? = null,
val category: String? = null
)

Mappers​

Dedicated mapper classes convert between DTOs and domain entities, handling nullability, defaults, and date parsing:

@Singleton
class DeviceMapper @Inject constructor() {

fun toDomain(dto: DeviceDto): Device {
return Device(
id = dto.id,
name = dto.name,
status = DeviceStatus.fromString(dto.status),
lastUpdate = dto.lastUpdate?.let { parseInstant(it) },
permissions = mapPermissions(dto.permissions, dto.isOwner ?: true),
// ...
)
}

fun toDomainList(dtos: List<DeviceDto>): List<Device> = dtos.map { toDomain(it) }
}

Repository Implementations​

Implementations wire together APIs, mappers, caches, and DataStores.

Note: Three data-layer repositories β€” NotificationPermissionRepository, NotificationSettingsRepository, and NotificationStateRepository β€” live in data/repositories/ without corresponding domain interfaces. These are Android-specific concerns (runtime permission state, notification channel settings, UI notification state) that don't warrant abstraction through the domain layer.

@Singleton
class DeviceRepositoryImpl @Inject constructor(
private val deviceApi: DeviceApi,
private val positionApi: PositionApi,
private val deviceMapper: DeviceMapper,
private val positionMapper: PositionMapper,
private val deviceDataStore: DeviceDataStore
) : DeviceRepository {

override val devicesWithPositionsFlow: StateFlow<List<DeviceWithPosition>>
get() = deviceDataStore.devicesWithPositions

override suspend fun getDevices(): List<Device> {
// 1. Check DataStore cache
// 2. Fetch from API on miss
// 3. Map DTOs β†’ Entities
// 4. Update DataStore
}
}

Data flow through the repository:

WebSocket (Real-Time Updates)​

WebSocketManager maintains a persistent connection with auto-reconnect using fixed-delay reconnection (RECONNECT_DELAY_MS = 5_000L). It emits domain events through SharedFlows:

This ensures all ViewModels observing DeviceDataStore.devicesWithPositions automatically receive real-time position updates without explicit refresh calls.


UI Layer​

The UI layer uses Jetpack Compose with an MVI (Model-View-Intent) pattern.

MVI Architecture​

Every ViewModel extends IntentViewModel<I>, which provides a single entry point for user actions:

// Intent marker
interface BaseIntent

// Base ViewModel with centralized intent dispatch
abstract class IntentViewModel<I : BaseIntent> : ViewModel() {
open fun handle(intent: I) {
Logger.info("${javaClass.simpleName}: Handling intent",
mapOf("intent" to intent.javaClass.simpleName))
reduce(intent)
}

protected abstract fun reduce(intent: I)

protected fun handleAsync(intent: I, block: suspend () -> Unit) { ... }
}

Each screen defines its own sealed intent class:

sealed class DeviceIntent : BaseIntent {
data object LoadDevices : DeviceIntent()
data object RefreshDevices : DeviceIntent()
data object PullToRefresh : DeviceIntent()
data class SelectDevice(val deviceId: Int?) : DeviceIntent()
data class UnclaimDevice(val deviceId: Int) : DeviceIntent()
data object ClearOperationResult : DeviceIntent()
// ...
}

UI State​

State is modeled as a sealed class, making impossible states unrepresentable:

sealed class DevicesUiState {
object Loading : DevicesUiState()

data class Success(
val devices: List<DeviceWithPosition>,
val isWebSocketConnected: Boolean,
val licenseStatus: License?
) : DevicesUiState()

data class Error(
val message: String,
val devices: List<DeviceWithPosition> = emptyList()
) : DevicesUiState()

data class Refreshing(
val devices: List<DeviceWithPosition>,
val isWebSocketConnected: Boolean
) : DevicesUiState()
}

ViewModel Structure​

ViewModels inject use cases / interactors (never repositories directly) and manage StateFlow-based UI state:

@HiltViewModel
class DevicesViewModel @Inject constructor(
@param:ApplicationContext private val appContext: Context,
private val devicesInteractor: DevicesInteractor,
private val licenseInteractor: DeviceLicenseInteractor,
private val realTimeDataBridge: RealTimeDataBridge,
private val observeConnectionStateUseCase: ObserveConnectionStateUseCase
) : IntentViewModel<DeviceIntent>() {

private val _uiState = MutableStateFlow<DevicesUiState>(DevicesUiState.Loading)
val uiState: StateFlow<DevicesUiState> = _uiState.asStateFlow()

override fun reduce(intent: DeviceIntent) {
when (intent) {
is DeviceIntent.LoadDevices -> loadDevices()
is DeviceIntent.RefreshDevices -> refreshDevices()
// ...
}
}
}

Screen Organization​

Screens follow a co-located pattern β€” each screen and its ViewModel live in the same screens/ package:

screens/
β”œβ”€β”€ DevicesListScreen.kt + DevicesViewModel.kt + DevicesUiState.kt
β”œβ”€β”€ DeviceDetailScreen.kt + DeviceDetailViewModel.kt
β”œβ”€β”€ LoginScreen.kt + LoginViewModel.kt
β”œβ”€β”€ GeofenceEditorScreen.kt + GeofenceEditorViewModel.kt
β”œβ”€β”€ SharingScreen.kt + SharingViewModel.kt
└── ... (~30 screen/viewmodel pairs)

Core Layer​

The core layer provides cross-cutting infrastructure that doesn't belong to any single feature.

Token Management​

TokenManager stores JWT tokens in encrypted DataStore with an in-memory cache for synchronous access by OkHttp interceptors. TokenAuthenticator automatically refreshes expired tokens using a separate @NoAuthClient OkHttp instance to avoid circular dependencies.

Observable DataStores​

Core DataStores (DeviceDataStore, GeofenceDataStore, etc.) hold the single source of truth for domain data as StateFlows. RealTimeDataBridge subscribes to WebSocket events and pushes updates into these stores:

This eliminates the need for manual refresh β€” any ViewModel observing a DataStore's StateFlow receives updates automatically.


DI Layer​

All dependency wiring uses Hilt with @InstallIn(SingletonComponent::class) for app-scoped singletons.

ModuleResponsibility
RepositoryModuleBinds 11 domain repository interfaces β†’ data implementations
NetworkModuleProvides OkHttp clients, Retrofit instance, all API interfaces
TokenManagerModuleProvides TokenManager and EncryptedTokenDataStore
ServiceModuleBinds PushTokenManager β†’ FCMTokenManagerImpl
BillingModuleProvides billing (with debug/release build variants)
BaseUrlModuleEnvironment-specific API base URL
AppModuleLocaleManager singleton
AppPreferencesModuleSharedPreferences / DataStore instances

Repository binding example:

@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {

@Binds @Singleton
abstract fun bindDeviceRepository(impl: DeviceRepositoryImpl): DeviceRepository

@Binds @Singleton
abstract fun bindAuthRepository(impl: AuthRepositoryImpl): AuthRepository

// ... 11 bindings total
}

Widget Layer​

The widget/ package is a self-contained concern for the Android home screen widget. It uses Jetpack Glance and has its own data pipeline:

  • DeviceGridWidget β€” Glance widget rendering a device grid
  • WidgetDataStore β€” Persisted widget state (independent from app DataStores)
  • WidgetSyncWorker β€” WorkManager periodic sync to keep widget data fresh
  • WidgetDevice / WidgetGridBuilder β€” Widget-specific models and layout

The widget syncs data via WidgetSyncWorker rather than sharing the app's DataStores, ensuring the widget functions even when the app process is not running.


Design Decisions​

Why Clean Architecture?​

Testability β€” Domain use cases can be unit-tested with mock repositories. No Android framework needed. ViewModels can be tested by providing fake use cases and asserting state transitions.

Maintainability β€” Changes to the API (new fields, endpoint changes) only affect the Data layer. The UI and business logic remain untouched. Similarly, switching from Retrofit to Ktor or from Compose to a different UI toolkit would not affect the Domain layer.

Team scalability β€” Clear boundaries let multiple developers work in parallel. One developer can build a new screen while another implements the backing API, as long as they agree on the repository interface.

Why Single-Module?​

The app uses a single Gradle module with package-based separation rather than multi-module architecture. This was a deliberate trade-off:

  • Reduced build complexity β€” No module dependency graph to manage, no cross-module visibility issues, simpler Hilt setup.
  • Faster iteration β€” A single module compiles as one unit. For an app of this size (~30 screens, 12 repositories), the build time cost of a single module is negligible.
  • Sufficient enforcement β€” Package-level organization with consistent naming conventions provides adequate separation. The dependency rule (UI β†’ Domain ← Data) is enforced by convention and code review, which works well for a small team.

If the app grows significantly (50+ screens, multiple teams), migrating to multi-module would be straightforward because the package structure already mirrors a module boundary.

Why Feature-Based Use Case Organization?​

Use cases are grouped by feature domain (auth/, devices/, geofences/) rather than by technical role:

usecases/
β”œβ”€β”€ auth/ # Login, register, 2FA, password reset
β”œβ”€β”€ devices/ # Claim, unclaim, update, fetch
β”œβ”€β”€ geofences/ # CRUD operations
β”œβ”€β”€ sharing/ # Share, revoke, invites
└── ...

This mirrors how the team thinks about the product. When working on "device sharing", all relevant use cases are in one place. It also scales naturally β€” adding a new feature means adding a new subdirectory, not scattering files across existing packages.

Why Domain Exceptions Over Result Type?​

The app uses a sealed exception hierarchy (DomainException) rather than Kotlin's Result type or a custom Either/Resource wrapper:

  • Contextual data β€” Exceptions carry typed fields (TwoFactorRequiredException.tempToken, DeviceLimitReachedException.limit) that drive UI behavior.
  • Flow control β€” ViewModels catch specific exception types and react accordingly (e.g., TwoFactorRequiredException triggers a navigation to the 2FA screen).
  • Simplicity β€” Use cases return the success type directly (suspend fun invoke(): Device) instead of wrapping in Result<Device>. This keeps the call site clean.
  • Coroutine compatibility β€” Kotlin coroutines have built-in exception propagation. The sealed hierarchy integrates naturally with try/catch in ViewModel handleAsync blocks.

Why Separate core/ for Cross-Cutting Concerns?​

Infrastructure like token management, network configuration, and observable DataStores doesn't belong in the Domain layer (it has Android dependencies) or the Data layer (it's not feature-specific). The core/ package provides a home for these concerns:

  • core/network/ β€” TokenManager, AuthInterceptor, TokenAuthenticator form the auth infrastructure used across all API calls.
  • core/data/ β€” Observable DataStores (DeviceDataStore, RealTimeDataBridge) serve as the single source of truth, consumed by both repositories and ViewModels.
  • core/cache/ β€” Generic MemoryCache with TTL, reused by multiple repositories.

This prevents the Data layer from becoming a dumping ground for shared utilities and keeps feature-specific code cleanly separated from infrastructure.

Known layer-boundary exceptions. The strict dependency rule (UI β†’ Domain ← Data) has a few pragmatic violations:

  • GeofencesListViewModel directly imports GeofenceRepositoryImpl from the data layer, bypassing the domain abstraction.
  • Three files in core/ import from data/: FCMTokenManagerImpl, TokenAuthenticator, and AppInitializer.

These are known tech debt, not intentional patterns. They exist because of expedient wiring choices that haven't yet been refactored behind domain interfaces.

Why MVI with IntentViewModel?​

The IntentViewModel<I> base class enforces MVI by funneling all user actions through a handle(intent) β†’ reduce(intent) pipeline:

  • Single entry point β€” Every action goes through handle(), enabling centralized logging, analytics, and debugging.
  • Predictable state β€” Sealed intent classes make all possible actions explicit and exhaustively handled.
  • Testable β€” Tests dispatch intents and assert on resulting StateFlow values.
  • Replay/debug β€” Intent streams can be logged and replayed for debugging.

Why Co-Located Screens and ViewModels?​

Screen composables and their ViewModels live together in ui/screens/ rather than in separate ui/screens/ and ui/viewmodels/ packages:

  • Locality β€” When modifying a screen, both the UI and its ViewModel are adjacent.
  • Shared ViewModels exception β€” ViewModels shared across multiple screens (e.g., MapViewModel, NotificationSettingsViewModel) are placed in ui/viewmodels/.