A two-screen marketplace client, structured as a single Gradle module that splits into many without a rewrite.
Status: reference implementation. Single module, fully tested, release-signable with R8. Not wired to CI, and it carries a few deliberate dev-only settings documented under Known gaps. Read "production-shaped," not "production-deployed."
Core knows nothing about features. Features never reach sideways.
core owns the infrastructure - networking, the persistence store, sharing, the navigation host, the design system - and depends on no feature. Each feature/* owns one product surface, layers itself presentation -> domain -> data, and reaches only into core or its own layers.
Most apps stay single-module until build times and ownership force a split, by which point dependencies point everywhere and the migration is expensive. Drawing the seam early costs nothing now and makes the later extraction mechanical: when a feature graduates to :feature:itemlist, its dependency edges already point the right way, so only the files move.
app/src/main/java/com/pzverkov/socialapp
SocialAppApplication.kt # Hilt application entry point
MainActivity.kt
core/
navigation/ # SocialAppNavHost, typed routes, deep links
network/ # Retrofit/OkHttp setup, NetworkResult
sharing/ # ShareLinkBuilder, InstallationIdProvider
store/ # persistence primitives
ui/ # design system: theme, components, image loading
feature/
itemlist/
data/ # SocialAppApi, ItemRepositoryImpl, DTOs
domain/ # Item model, ItemRepository contract
presentation/ # ItemListScreen, ViewModel, UI models
itemdetail/
presentation/ # ItemDetailScreen, ViewModel, UI models
favorite/
data/ # SocialAppDatabase (Room), DAO, repository
domain/ # FavoriteRepository contract
The packages already name the module graph they will become:
:app
:core:network :core:ui :core:navigation :core:store :core:sharing
:feature:itemlist :feature:itemdetail :feature:favorite
Each :feature:* depends on the :core:* modules it uses and exposes its public surface through domain. :app assembles the graph through Hilt. No feature imports another, so the split untangles nothing.
- Kotlin, JVM target 17,
compileSdk 36/minSdk 26/targetSdk 36 - Jetpack Compose + Material 3, Compose Navigation for routing and deep links
- Hilt for dependency injection
- Retrofit + OkHttp + kotlinx.serialization for networking
- Room for favorites
- Coil for image loading
- JUnit, Turbine, Compose UI tests; Kover for coverage gates; R8 for release
Deep links resolve to content, not the home screen. A custom scheme socialapp://item/{id} and verified App Links on https://socialapp.app/item/{id} both land on the item. Shared links carry an 8-character installation id (?ref=) so sharing is attributable without collecting PII. App Links need an assetlinks.json on the domain in production; the custom scheme works at install time.
Hilt owns the object graph; tests swap bindings, not internals. A view model never learns it is under test - the fake repository arrives through the binding the real one would.
Coverage is a floor the build enforces. Kover fails below 65% line and 60% branch. Generated and UI-only code is excluded, so the number measures logic rather than Compose boilerplate. There are no screenshot or end-to-end tests yet.
Accessibility is treated as acceptance criteria. 48dp touch targets, WCAG contrast ratios, live regions on state changes, and errors typed and mapped to localized strings.
Lint fails the build. abortOnError is on; known issues live in a checked-in baseline so they stay visible rather than suppressed inline.
Release is minified and signed from credentials that never touch git. R8 shrinks code and resources. Keep rules cover only what R8 cannot infer (kotlinx.serialization serializers); the rest ship consumer rules. Signing reads keystore.properties or CI environment variables; miss both and the build produces an unsigned APK rather than one signed with the debug key. R8 emits a mapping.txt per release build - retain it per version to deobfuscate production stack traces.
The build is reproducible across machines. Every plugin and dependency version lives in one Gradle version catalog (gradle/libs.versions.toml), and the Gradle daemon is pinned to a JDK 17 toolchain (gradle/gradle-daemon-jvm.properties, auto-provisioned via the foojay resolver), so CLI, IDE, and CI compile on the same JVM instead of whatever happens to launch Gradle.
./gradlew :app:assembleDebug # debug APK
./gradlew test # JVM unit tests
./gradlew koverHtmlReport # coverage report
./gradlew koverVerify # enforce the coverage floor
./gradlew connectedAndroidTest # instrumentation tests (device/emulator)
./gradlew :app:assembleRelease # minified, signed release APK
Release signing pulls from a gitignored keystore.properties. Set it up once:
keytool -genkeypair -v -keystore app/release.jks -alias socialapp \
-keyalg RSA -keysize 2048 -validity 10000
cp keystore.properties.template keystore.properties # then fill in the passwords
CI can skip the file and export KEYSTORE_FILE, KEYSTORE_PASSWORD, KEY_ALIAS, KEY_PASSWORD instead. The release key is the app's identity for its lifetime; in a real deployment it belongs in a secret manager or hardware-backed store, not on a developer machine.
A dependency-free Node.js mock server under mock-server/ serves the API locally (nothing to install):
cd mock-server && node server.js
Exercise a deep link against a running build:
adb shell am start -a android.intent.action.VIEW -d "socialapp://item/1"
Named on purpose, roughly in priority order:
- Cleartext traffic is enabled (
usesCleartextTraffic="true") to talk to the local mock server. Production needs this off and anetwork-security-configthat pins or at least restricts to HTTPS. - No CI. The Gradle tasks above are the contract a pipeline would run: assemble, unit tests,
koverVerify, lint, instrumentation tests, signed release. - No crash reporting or observability.
mapping.txtretention is set up, but nothing consumes it yet. Wiring a reporter is a prerequisite for trusting a release. - The module split is designed, not performed. The seams are real and the version catalog already shares versions across modules; carving
build.gradleper module is the next commit.
For the full design rationale - the Store abstraction, error handling, testing philosophy, performance work, and the feature-onboarding playbook - see ARCHITECTURE.md.