Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

475 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

BillionBeers 🍻

A production-shaped, multi-module Android app — a beer catalog — used as a proving ground for modern Android architecture. The shape is the product: the boundaries that matter here fail the build rather than fail a code review, and the decisions that are easy to second-guess are written down as ADRs instead of argued twice.

Compose UI · Metro DI · Room as SSOT · hand-rolled paging · two on-demand dynamic feature modules · architecture rules enforced by Konsist · JVM screenshot tests · dependency verification.

Google Play Ask DeepWiki


📸 Visual Tour

Captured from the current build with scripts/play-listing.sh — the same assets that go to the Play Store, so they cannot quietly drift from the app again.

![Catalog](imagesForReadme/catalog.jpg)
<!-- slide -->
![Search as you type](imagesForReadme/search.jpg)
<!-- slide -->
![Beer detail](imagesForReadme/detail.jpg)
<!-- slide -->
![Browse by style](imagesForReadme/browse-by-style.jpg)
<!-- slide -->
![Dark theme](imagesForReadme/dark-theme.jpg)

🏗 Architecture & Design Patterns

The project follows Clean Architecture principles with a robust Multi-module structure, ensuring high scalability and separation of concerns.

High-Level Module Dependency

Dependencies point inwards: features and data both depend on the domain, and the domain depends on nothing but pure Kotlin. The load-bearing edges here — features never reaching each other, the domain staying Android-free, data never leaking upwards — are checked by Konsist tests on every push.

graph TD
    App[":app"]

    subgraph FEATURES ["Features — never depend on each other"]
        List[":feature:beerslist"]
        Search[":feature:beersearch"]
        Detail[":feature:beerdetail<br/><i>on-demand</i>"]
        Browse[":feature:beerbrowse<br/><i>on-demand</i>"]
    end

    subgraph SHARED ["Shared UI"]
        Nav[":navigation"]
        PresUtils[":presentation_utils"]
        Design[":core:designsystem"]
    end

    subgraph DATA ["Data — implements the domain interfaces"]
        Data[":beer_data"]
        Network[":beer_network"]
        DB[":beer_database"]
    end

    subgraph DOMAIN ["Domain — pure JVM, zero Android"]
        Api[":beerdomain:api<br/><i>models · repo interfaces · typed errors</i>"]
        Fakes[":beerdomain:fakes"]
    end

    CoreCommon[":core-common<br/><i>pure JVM · paging · Either · seams</i>"]

    App --> FEATURES
    App --> DATA
    FEATURES --> SHARED
    FEATURES --> Api
    SHARED --> Api
    Data --> Network
    Data --> DB
    Data --> Api
    Fakes --> Api
    Api --> CoreCommon
    SHARED --> CoreCommon

    classDef dyn stroke-dasharray: 5 5
    class Detail,Browse dyn
Loading

Note

The two dashed modules invert their build edge. Android's com.android.dynamic-feature plugin requires an on-demand feature to declare implementation(project(":app")), while :app lists it under dynamicFeatures. The code dependency still runs the direction drawn above — features never reach into :app, and cross-feature navigation goes through :navigation.

Shared foundation dependencies (:core, :core-common, :presentation_utils, :beerdomain:api) are injected into every feature by the billionbeers.android.feature convention plugin rather than hand-declared per module.

Feature-Level: Unidirectional Data Flow (UDF)

Every feature uses a pure UDF pattern powered by Kotlin Flow and Compose state. There is no use case layer — ViewModels inject the domain repository interface directly, which is only safe because a Konsist rule mechanically forbids a ViewModel from touching anything outside the domain layer. The reasoning is written down in ADR 0003.

sequenceDiagram
    participant UI as Compose Screen
    participant VM as ViewModel
    participant Pager as Pager (screen-scoped)
    participant Repo as BeersRepository<br/>(domain interface)
    participant Impl as Repository impl<br/>(:beer_data)

    UI->>VM: Intent (open screen · scroll to end)
    VM->>Repo: catalogCacheStatus(policy)
    Repo-->>VM: Fresh / Stale / Empty
    Note over VM: A fresh cache skips the fetch entirely —<br/>Room is the source of truth, not the network.
    VM->>Pager: loadFirstPage() / nextPage()
    Pager->>Repo: getBeersPageFromApi(page, query)
    Repo->>Impl: bound by Metro
    Impl-->>Repo: BeerPage (items + server total)
    Pager->>Repo: insertPage(...) — page + resume key, one transaction
    Pager-->>VM: data + PagingState
    VM->>VM: PagedListReducer folds page into PagedListUiModel<br/>(errors arrive typed, as FetchBeersError)
    VM-->>UI: StateFlow<CommonUiState<PagedListUiModel<Beer>>>
    Note over VM,UI: One-shot effects go over<br/>Channel(BUFFERED).receiveAsFlow(),<br/>never SharedFlow — it drops events<br/>with no active collector.
Loading

🛡 Enforced Architecture

The point of this repository is that the conventions are enforced by tooling, not by memory. A diagram that only lives in a README rots; these rules fail the build. They run as Konsist tests in the :konsist module, on every push.

Rule Test
Repository interfaces never import data-layer types RepositoryBoundaryTest
Feature modules never depend on other feature modules — cross-feature nav goes through :navigation FeatureModuleBoundaryTest
The domain layer has zero Android imports DomainLayerPurityTest
ViewModels depend only on domain types (the precondition that makes "no use cases" safe) ViewModelBoundaryTest
Dynamic features declare no resources of their own — they crash instrumented tests DynamicFeatureResourceBoundaryTest
Dev-app sandboxes depend only on api + fakes modules, which is what keeps them fast DevAppDependencyBoundaryTest
No module applies java-test-fixtures — fixtures live in sibling :fakes modules (ADR 0001) TestFixturesPluginBoundaryTest
ViewModels never use MutableSharedFlow — it drops one-shot events when nothing is collecting OneShotEventBoundaryTest
Domain models are immutable — no var, and no val holding a mutable collection DomainModelImmutabilityTest
A module with src/androidTest/ opts into the managed device — otherwise its tests compile, read as coverage, and never run InstrumentedTestOptInBoundaryTest
Every src/ directory has a build script beside it — an orphaned source tree is invisible to Gradle but still found by grep OrphanedSourceTreeTest
Test-only libraries never sit on implementation/api — they ship in the release APK TestLibraryBoundaryTest

Reinforced by:

  • Supply chain — Gradle dependency verification with a checked-in ledger (211 locked deps), dependency-guard on the resolved graph, GitHub Actions pinned to SHAs, and gitleaks on every PR range. See ADR 0006 and ADR 0007.
  • Convention plugins — module setup lives in build-logic, so a new feature module is a plugin id and a namespace, not a copied 80-line build script.
  • Decision record — twelve ADRs covering the choices that are easy to second-guess: no Paging3, no java-test-fixtures, no use-case layer — and ADR 0010, which records the capabilities this project deliberately doesn't have (auth, pinning, push, background sync) and the premise each one is waiting on, so a deliberate absence never has to be mistaken for an oversight.
  • Dev-app sandboxesapp-dev-<feature> modules build a single feature against fakes for fast iteration (make new-dev-app).
  • A budget on the build itself, not just the appmake build-budget measures clean, incremental and test builds with gradle-profiler and checks them against config/build-time-budget.txt. Clean build is 37s cold and 4s warm; a deep ABI change costs 1.11x a leaf one, which says per-build overhead dominates and further module splitting would not make builds faster. It runs locally, never in CI, because a CI wall-clock number mostly measures which runner the job drew. See ADR 0011.
  • CI that runs only what a change can break — a push to a PR reruns the test lanes its diff can affect, plus any lane that was red on the previous head; unaffected green lanes adopt their previous verdict, and a docs-only PR skips the heavy lanes entirely. The rules live in one function, so changing what runs when is a single edit. See ADR 0008.

🛠 Advanced Technology Stack

This project goes beyond standard libraries, incorporating advanced engineering tools:

  • UI: Jetpack Compose with a Component Catalog (annotation-driven demo system).
  • DI: Metro — A cutting-edge, high-performance dependency injection framework for dynamic features.
  • Testing:
    • Paparazzi: JVM-based Snapshot Testing. It renders your Composables directly on the JVM using Android Studio's LayoutLib, allowing for lightning-fast regression testing without emulators.
    • Robot Pattern: Standardized E2E/UI testing architecture for readability.
  • Data: Room (SSOT), Retrofit, Kotlin Serialization, and a hand-rolled PagingMediator — Paging 3 was deliberately dropped because PagingData leaks through every layer (ADR 0002).
  • Errors: typed sealed errors carried in Either<DomainError, T>, converted at the data boundary — never an untyped Exception on the left.
  • Quality: Konsist (architecture), Detekt, Spotless, Jacoco (Unified Root Reporting), dependency-guard, macrobenchmark perf budgets, and Baseline Profiles.

🚀 Project Evolution

Click to explore the technological journey (19 Milestones)

This repository has served as a technological sandbox over the years. Each milestone below is a live branch you can check out and read:

  1. Monolithic App with Dagger2: The original project structure.
  2. Hilt Monolith: Transitioning to modern DI.
  3. Simple Multi-Module (Hilt): First architectural split.
  4. Complete Multi-Module Architecture: Mature layered separation.
  5. Standard Dynamic Features: Base implementation of Play Core features.
  6. On-Demand Dynamic Features: Advanced lazy loading.
  7. SonarQube Integration: Static analysis at scale.
  8. SonarQube + Jacoco: Unified coverage reporting.
  9. Jetpack Compose Migration: Modernizing the entire UI layer.
  10. Kotlin DSL (KTS) Migration: Type-safe Gradle configuration.
  11. Centralized Version Catalog: Managing dependencies in a single toml file.
  12. Build Logic Unification: Industry-standard precompiled script plugins.
  13. Design System Catalog: Standalone component documentation app.
  14. Baseline Profiles: DEX layout optimization for app startup.
  15. Dependency Auditing: Custom plugin for unused dep detection.
  16. Advanced R8 Aggressiveness: Maximum code shrinking and obfuscation.
  17. KSP Compose Processing: Transitioning from KAPT to KSP for build speed.
  18. Full Compose Navigation: Migration to type-safe Compose Nav.
  19. Assisted Inject Experiments: Dynamic parameters in DI.

Everything after this point landed on master rather than on its own branch — the Metro DI migration, Navigation 3, the hand-rolled paging layer, on-demand dynamic-feature install handling, the Konsist rule set, Gradle dependency verification, and per-lane CI test selection. Read docs/adr/ for the decisions and git log for the work.


📊 Current Stack Versions

Note

Generated by make update-docs, and CI fails if this table drifts from libs.versions.toml. So it is accurate by construction rather than by anyone remembering.

Tech Version
Kotlin 2.4.10
Gradle 9.6.1
Compose BOM 2026.06.01
Metro DI 1.3.2
Room DB 2.8.4

To simplify development, we use a standardized Makefile. Run make help to see all available commands.

Tip

You can target specific modules using the MODULE variable: make test MODULE=:feature:beerslist

  • Build/Install: make build, make install
  • Testing: make test, make ui-test
  • Screenshots: make screenshot-record, make screenshot-verify
  • Analysis: make check-unused-deps, make check-duplicates
  • Benchmarking: make benchmark-macro, make gradle-benchmark SCENARIO=clean_build

🤝 Contributing

Contributions are what make the open-source community such an amazing place to learn, inspire, and create. Any contributions you make are greatly appreciated.

  1. Fork the Project
  2. Create your Feature Branch (git checkout -b feature/AmazingFeature)
  3. Commit your Changes (git commit -m 'Add some AmazingFeature')
  4. Push to the Branch (git push origin feature/AmazingFeature)
  5. Open a Pull Request

Built with ❤️ by Simon Topchyan

About

Production-shaped multi-module Android app where the architecture rules are enforced by CI, not convention. Compose · Metro DI · Room SSOT · hand-rolled paging · on-demand dynamic features · Konsist architecture tests · Paparazzi screenshots · Gradle dependency verification.

Topics

Resources

Stars

11 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages