Architecture &
Technical Reference
How EU Stats Multiplatform is put together — the contracts, the data flow, the conventions that make 17 Gradle modules ship the same product on Android, iOS, and desktop.
17 Gradle modules, three tiers
Core modules define reusable contracts. Feature modules own their data flow end-to-end. The app shell wires everything together.
Multi-module Clean Architecture. Cores are reusable contracts; feature modules own their data flow end-to-end; composeApp wires them together.
Stale-while-revalidate, step by step
Every feature module follows the same flow: emit Loading immediately, serve cached data as stale, revalidate in the background, suppress network errors if a stale cache was already emitted.
Stale-while-revalidate: cached data reaches the UI before the network call completes. The StaleBanner component indicates revalidation in progress and disappears on fresh success.
One parser. 18 datasets.
The parser lives in core-jsonstat/JsonStatParser.kt and is dataset-agnostic. Feature-specific shaping happens in per-feature CellMapper extensions.
Generic unpacker — no feature-specific logic. The 18 Eurostat datasets all flow through this same parser before being shaped into typed domain models by per-feature CellMapper extensions.
Eight chart types. Pure Compose Canvas implementation — no third-party chart libraries. ~1500 LOC total in core-charts/.
18 Eurostat datasets with canonical filter maps
All codes and filter values verified against the live Eurostat dissemination API. API base: https://ec.europa.eu/eurostat/api/dissemination/statistics/1.0/data/{code}?format=JSON&lang=EN
| Feature | Dataset code | Key filters | Output / unit | Notes |
|---|---|---|---|---|
| population | demo_pjangroup |
sex=T/M/F age=TOTAL + 18 5-yr cohorts (Y_LT5..Y_GE85) |
persons | NOT demo_pjan — that is per-year only. |
| economy | nama_10_gdp |
unit=CP_MEUR na_item=B1GQ |
million EUR (current prices) | |
| economy | prc_hicp_aind |
unit=INX_A_AVG coicop=CP00 |
index (2015=100) | NOT I15. |
| economy | gov_10dd_edpt1 |
unit=PC_GDP na_item=B9 sector=S13 |
% of GDP | Government net lending/borrowing. |
| environment | env_air_gge |
airpol=GHG src_crf=TOTX4_MEMO / CRF1A3 / CRF1A2 unit=MIO_T |
Mt CO₂-eq | TOTX4_MEMO for total GHG (NOT TOTAL). |
| environment | nrg_bal_c |
siec=TOTAL nrg_bal=FC_E / FC_TRA_E / FC_IND_E unit=KTOE |
ktoe | FC_E for total final consumption (NOT FC or TOTAL). |
| environment | sdg_13_10 |
unit=I90 |
index (1990=100) | NOT I15. |
| trade | ext_lt_intratrd |
indic_et=MIO_EXP_VAL / MIO_IMP_VAL / MIO_BAL_VAL sitc06=TOTAL |
million EUR | No unit dim — unit is embedded in indic_et. Without sitc06=TOTAL, API returns 24 rows (one per SITC product) causing last-cell-wins mapping errors. |
| transport | road_pa_buscoa |
unit=THS_PAS tra_cov=TOTAL |
thousand passengers (×1000 in mapper) | NO vehicle dim — not defined in this dataset. |
| transport | avia_paoc |
unit=PAS tra_meas=PAS_CRD tra_cov=TOTAL schedule=TOT |
passengers carried | NO partner dim — not defined in this dataset. |
| transport | sea: disabled | mar_pa_aa — port-based dim, not geo |
n/a | Intentionally excluded; port-keyed dim is incompatible with country-level display. |
| tourism | tour_occ_ninat |
c_resid=DOM / FOR / TOTAL unit=NR nace_r2=I551-I553 |
nights | FOR = foreign/international (NOT INTL). |
| tourism | tour_dem_tttot |
unit=NR purpose=TOTAL duration=N_GE1 c_dest=WORLD |
trips | Dim is c_dest, NOT partner. No c_resid dim in this dataset. |
| social | ilc_li02 |
unit=PC indic_il=LI_R_MD60 sex=T age=TOTAL |
% (povertyRate) | |
| social | ilc_peps01 |
unit=PC sex=T age=TOTAL |
% (atRiskRate) | NO indic_il dim — not defined in this dataset. |
| social | hlth_silc_01 |
levels=VGOOD sex=T age=Y_GE16 wstatus=POP |
% (healthSatisfaction) | |
| science | rd_e_gerdtot |
unit=PC_GDP sectperf=TOTAL |
% of GDP (R&D expenditure) | |
| science | isoc_ci_ifp_iu |
unit=PC_IND ind_type=IND_TOTAL indic_is=I_IU3 |
% individuals (internet usage) | |
| science | edat_lfse_03 |
unit=PC isced11=ED5-8 sex=T age=Y25-64 |
% (tertiary education 25–64) |
Repository contract
Every feature repository implements the same sealed Result<T> + stale-while-revalidate contract. No exceptions cross the data/domain boundary.
// core-common: Result<T> sealed interface
sealed interface Result<out T> {
data object Loading : Result<Nothing>
data class Success<T>(val data: T, val isStale: Boolean = false) : Result<T>
data class Error(val error: AppError) : Result<Nothing>
}
// core-common: AppError sealed hierarchy
sealed interface AppError {
data class NetworkError(val cause: Throwable?) : AppError
data class ParseError(val message: String) : AppError
data class CacheError(val cause: Throwable?) : AppError
data object NotFound : AppError
data object Unknown : AppError
}
// feature-xxx: repository interface (domain layer)
interface XxxRepository {
fun observe(query: XxxQuery): Flow<Result<List<XxxDataPoint>>>
}
// implementation sketch (data layer)
override fun observe(query: XxxQuery) = flow {
emit(Result.Loading)
val cached = cache.read(query.cacheKey)
if (cached != null) emit(Result.Success(cached, isStale = true))
try {
val fresh = api.fetch(query).let(mapper::fromCells)
cache.write(query.cacheKey, fresh)
emit(Result.Success(fresh, isStale = false))
} catch (e: CancellationException) { throw e } // always rethrow
catch (e: Exception) {
if (cached == null) emit(Result.Error(AppError.NetworkError(e)))
// else: stale cache was emitted — swallow silently
}
}.flowOn(dispatchers.io)
The repository emits Loading immediately, then emits cached Success(isStale=true) if the cache has data, then revalidates via the network. On network success it writes to cache and emits Success(isStale=false). On network failure it emits Error only if no stale cache was available; otherwise the failure is swallowed silently and the StaleBanner remains visible. CancellationException is always rethrown — never swallowed — so structured concurrency works correctly. No other exception crosses the data/domain boundary; everything surfaces as a typed Result.Error(AppError).
444 unit tests. Zero failures.
Tests run on JVM via commonTest. KMP-friendly fakes — no Mockk (iOS would reject it). Turbine for Flow assertions: flow.test { assertEquals(Loading, awaitItem()); ... }.
Measured, not estimated
- Debug APK size 19 MB
- Compose Desktop UberJar ~99 MB (self-contained; includes Skia native libraries for macOS / Linux / Windows)
- JSON-stat parser complexity O(n × d) where n = value count, d = dimension count. Sub-millisecond on device for typical Eurostat datasets (10³–10&sup5; values, 2–4 dimensions).
- HTTP timeouts 30 s request · 10 s connect · 30 s socket. Exponential retry up to 2 attempts on 5xx.
- Cache TTL 12 hours per dataset. Stale-while-revalidate means cached data renders instantly while a background fetch revalidates.
- APK signing Not yet configured — Phase 6 task (see roadmap).
Gradle commands
# Android
./gradlew :composeApp:assembleDebug # build debug APK
./gradlew :composeApp:installDebug # install to connected device / emulator
./gradlew testDebugUnitTest # run all unit tests (JVM)
# Compose Desktop
./gradlew :composeApp:desktopRun # run desktop app on host OS
./gradlew :composeApp:packageUberJarForCurrentOS # build self-contained .jar
# Per-module tests
./gradlew :core-jsonstat:jvmTest # JSON-stat parser tests
./gradlew :feature-population:testDebugUnitTest
./gradlew :feature-economy:testDebugUnitTest
./gradlew :feature-environment:testDebugUnitTest
./gradlew :feature-trade:testDebugUnitTest
./gradlew :feature-transport:testDebugUnitTest
./gradlew :feature-tourism:testDebugUnitTest
./gradlew :feature-social:testDebugUnitTest
./gradlew :feature-science:testDebugUnitTest
# Code quality
./gradlew lint # Android Lint across all modules
./gradlew :composeApp:check # compile + test + lint
Project-wide coding rules
These rules apply across all 17 modules. They exist to make the codebase predictable for any contributor and cross-compilable on all KMP targets.
- Package root is
eu.eurostat.{module}— e.g.eu.eurostat.feature.population.domain. One class per file; file name matches public class name. - Sealed types as
sealed interface, notsealed class, unless state fields are needed. - Prefer extension functions over utility classes.
- No
!!operator anywhere — userequireNotNull,checkNotNull, or sealedResulthandling. - KDoc on every public type, every interface method, every non-obvious algorithm.
- Never reference
Dispatchers.IOorDispatchers.Defaultdirectly — injectDispatcherProviderand usedispatchers.io, etc. - No exceptions cross the data/domain boundary — every failure surfaces as
Result.Error(AppError).CancellationExceptionis always rethrown. - Unit tests in
commonTestwhenever possible (run on JVM, fast). Fakes implement the interface directly — no Mockk (KMP-incompatible on iOS). - Navigation via Decompose — not Voyager, not Compose Navigation. Component interface exposes
StateFlow<XxxUiState>andonIntent(XxxIntent). - All UI tokens come from
object Euro(colors, typography, spacing, shapes, moduleAccents) — no hardcoded values in feature screens.