This document captures the layering, module graph, and the foundational decisions that govern the codebase. New code must respect what is written here; changes to the architecture should be proposed, discussed, and then reflected back into this file.
- Strict layer boundaries. Dependencies point inward (presentation to domain to data). The UI never touches the network. Network DTOs never escape the data layer.
- One transit contract. The rest of the app depends on
TransitRepository; every operator implements it against its own data source. - Universal domain models.
Stop,Line,Arrival,Alert,Operatorare modelled once and reused by every provider. Provider-specific concepts stay inside provider modules. - Immutability and Flow. UI state is immutable; repositories expose cold
Flows so the UI observes continuous updates (essential for live arrivals). - Composition over inheritance. Small composables, one responsibility each.
- Accessibility is not optional. Every screen is navigable with TalkBack, respects dynamic font sizes, and stays usable at high contrast.
+-------------------------------------------------------------+
| Presentation |
| :app :feature-* :core-ui :core-design |
| Compose UI, ViewModels, navigation, theme |
+----------------------------+--------------------------------+
| depends on
v
+-------------------------------------------------------------+
| Domain |
| :core |
| Pure Kotlin. Models, repository contracts, AppResult |
+----------------------------+--------------------------------+
| implemented by
v
+-------------------------------------------------------------+
| Data |
| :provider-metrovalencia :core-network :core-data |
| Repository impls, Ktor HttpClient, DataStore, DTOs |
+-------------------------------------------------------------+
Rules:
- The UI never talks directly to networking.
- The UI never contains business logic.
- Business logic lives in the domain layer (use cases) or in the repository implementations.
- Network implementations stay inside providers.
- Network DTOs are never exposed outside the data layer. They are always mapped into domain models.
:app
+-- :core
+-- :core-data
+-- :core-network
+-- :core-design
+-- :core-ui -> :core, :core-design
+-- :feature-home -> :core, :core-data, :core-design, :core-ui
+-- :feature-search -> :core, :core-data, :core-design, :core-ui
+-- :feature-planner -> :core, :core-data, :core-design, :core-ui
+-- :feature-settings -> :core, :core-data, :core-design, :core-ui
+-- :provider-metrovalencia -> :core, :core-network
:core (pure Kotlin/JVM, no Android)
:core-data (Android library, DataStore preferences + favorites)
:core-network (Android library, Ktor)
:core-design (Android library, Compose theme)
Adding a feature: create :feature-<name> depending on :core, :core-design,
:core-ui. Wire its Koin module in :app.
Adding an operator: create :provider-<operator> depending on :core and
:core-network, implement TransitRepository, expose a Koin module, and
register it in :app.
Single source of truth. The ViewModel owns a StateFlow<UiState>; the screen
observes it via collectAsStateWithLifecycle and renders by switching on the
sealed UiState. User actions become events handed back to the ViewModel. The
UI never mutates state directly.
sealed interface HomeUiState {
data object Loading : HomeUiState
data class Ready(...) : HomeUiState
data class Error(...) : HomeUiState
}AppResult<T> and AppError (both in :core) are the single failure surface.
Network layers map their exceptions into AppError variants; the UI switches on
them to produce localised, actionable messages. The app never crashes on a
network failure.
AppError: Offline | Network | Timeout | Unauthorized |
Server(code) | NotFound | Parsing | Unknown
Koin, chosen over Hilt deliberately:
- Pure Kotlin DSL; no annotation processing, no KSP, faster builds.
- No reflection at runtime.
- Trivially removable if a future fork prefers another DI library.
- Each module exposes its own Koin
module { ... }; the application aggregates them inTransitOSApplication.
- Kotlin 2.0.21, Coroutines, Flow
- Jetpack Compose (BOM-managed), Material 3, dynamic colour
- Ktor 3 for HTTP (one shared
HttpClient; providers plug in their own endpoints) - Koin 4 for dependency injection
- DataStore for preferences and favorites (wired in
:core-data) - osmdroid for the network map
- Gradle Version Catalog (
gradle/libs.versions.toml) as a single source for every dependency and plugin
Room and Coil are declared in the version catalog but not yet wired. They will land when the first feature that needs them (an offline cache, image loading) arrives.
- Package root:
app.transitos.<module-suffix>(for exampleapp.transitos.feature.home). Note theapplicationIdand manifest namespace usecom.glossostudio.transitos. - Public API in
:coreis explicit (-Xexplicit-api=warning): visibility is always stated, never implied. - Composables follow the
ScreentoContenttoComponenttoPrimitivehierarchy. Keep them small and stateless; hoist state to the route-level composable or the ViewModel. - Module build files use Kotlin DSL. There are no
buildSrcconvention plugins yet; we will extract them once duplication across modules justifies it.
- Koin, not Hilt. Simplicity and Kotlin idioms win.
- Flat module layout (
:core,:core-network,:feature-*,:provider-*), following the Breezy Weather convention. Easier to reason about than nested:core:domain/:core:data. Stopis the universal boarding-point concept. A metro platform and a bus pole are the same thing from the app's perspective. A separateStationtype is deliberately avoided; group facilities withStop.parentStationId.- AGPL-3.0. Strong copyleft. Improvements must stay open, including when offered as a service.