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.

Module graph

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.

TIER 1 · CORE TIER 2 · FEATURES TIER 3 · APP SHELL core-common Result · AppError core-jsonstat JSON-stat 2.0 core-network Ktor HttpClient core-database SQLDelight DAOs core-navigation Decompose root core-ui 20 components core-charts 8 Canvas charts population demo_pjangroup economy nama · hicp · gov environment ghg · energy · sdg trade ext_lt_intratrd transport road · avia tourism occ · dem social silc · eu-silc science rd · isoc · edat settings Phase 5 planned composeApp ScrollableTabRow · BottomTabBar iosApp Xcode wrapper LEGEND core module feature module planned (Phase 5)

Multi-module Clean Architecture. Cores are reusable contracts; feature modules own their data flow end-to-end; composeApp wires them together.

Data flow

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.

User / UI Compose Screen Component Decompose UseCase domain layer Repository data layer Cache SQLDelight Eurostat API Ktor + OkHttp / Darwin taps tab init() useCase.observe(query) repository.observe() — IO DISPATCHER BOUNDARY (flowOn dispatchers.io) — emit Loading UiState.Loading cache.read(key) CacheHit(data) emit Success(data, isStale=true) StaleBanner shown api.fetch(dataset, filters) ✓ success JsonStatResponse cache.write(key, data) emit Success(data, isStale=false) StaleBanner hidden ✗ failure if(cached) suppress silently else emit Error(AppError) emit Error(NetworkError) — only if no cache — MAIN DISPATCHER BOUNDARY (collect on Main) — CancellationException always rethrown — never swallowed No other exception crosses the data/domain boundary

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.

JSON-stat parser

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.

JsonStatResponse size: List<Int> id: List<String> dimension: Map value: Map/List compute multipliers stride per dimension O(d) pre-pass supports any d ≥ 1 walk value-map each entry: index→value decode dim indices null-safe (sparse OK) List<JsonStatCell> dimensions: Map<dim, code> dimensionLabels: Map value: Double? then each feature's CellMapper.fromCells() shapes cells into typed domain models O(n × d) total · sub-millisecond for typical datasets (10³–10⁵ values, 2–4 dimensions)

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.

Chart library

Eight chart types. Pure Compose Canvas implementation — no third-party chart libraries. ~1500 LOC total in core-charts/.

Population Pyramid
feature-population
Multi-Line
feature-economy · environment
Diverging Bar
feature-trade
Small Multiples
feature-transport
Stacked Bar
feature-tourism
Heatmap
feature-tourism (seasonality)
Radar
feature-science
Multi-Line Highlighted
feature-social
Sparklines (×3)
feature-science
Dataset reference

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)
Core contract

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).

Testing

444 unit tests. Zero failures.

444
unit tests, 0 failures, 0 skipped
100%
of feature modules have ApiService + CellMapper + Repository + Component test classes
47
JSON-stat parser test cases — sparse arrays, dense arrays, null values, dimension reordering, integer overflow
SWR
Stale-while-revalidate verified per module: cache hit suppresses error; cache miss surfaces error; cancellation propagates

Tests run on JVM via commonTest. KMP-friendly fakes — no Mockk (iOS would reject it). Turbine for Flow assertions: flow.test { assertEquals(Loading, awaitItem()); ... }.

Performance characteristics

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).
Build & run

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
Conventions

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, not sealed class, unless state fields are needed.
  • Prefer extension functions over utility classes.
  • No !! operator anywhere — use requireNotNull, checkNotNull, or sealed Result handling.
  • KDoc on every public type, every interface method, every non-obvious algorithm.
  • Never reference Dispatchers.IO or Dispatchers.Default directly — inject DispatcherProvider and use dispatchers.io, etc.
  • No exceptions cross the data/domain boundary — every failure surfaces as Result.Error(AppError). CancellationException is always rethrown.
  • Unit tests in commonTest whenever 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> and onIntent(XxxIntent).
  • All UI tokens come from object Euro (colors, typography, spacing, shapes, moduleAccents) — no hardcoded values in feature screens.