$30 off During Our Annual Pro Sale. View Details »

Forging the path from monolith to multi-module app | droidcon Italy

Forging the path from monolith to multi-module app | droidcon Italy

Going from a monolith to a multi-module project is a long process, that requires careful planning and a well-thought strategy, especially in medium-large teams.

The TIER application is evolving to a multi-module structure and this talk will be the logbook of the journey, covering the approach that we followed and the philosophy behind the choices that we’ve made with both the mobile platforms in mind.

I will tell you how to balance the entire process without harming the regular feature delivery schedule, where to start modularizing and improvements to the build tools to make life less miserable.

Marco Gomiero

October 06, 2022
Tweet

More Decks by Marco Gomiero

Other Decks in Programming

Transcript

  1. @marcoGomier
    Marco Gomiero
    👨💻 Senior Android Engineer @ TIER

    Google Developer Expert for Kotlin
    Forging the path from
    monolith to


    multi-module app

    View Slide

  2. @marcoGomier
    We are a micro-mobility company, providing
    people e-scooters, e-bikes and e-mopeds,
    powered by a proprietary energy network
    TIER Mobility

    View Slide

  3. @marcoGomier
    🧑💻 👩💻

    View Slide

  4. @marcoGomier
    🧑💻 👩💻
    👨💻
    👨💻
    👨💻
    🧑💻 👩💻
    👩💻
    👨💻 👩💻
    👨💻
    👨💻
    👨💻
    👨💻
    👩💻
    👩💻

    View Slide

  5. @marcoGomier
    🧑💻
    👩💻
    👨💻
    👨💻
    👨💻
    🧑💻
    👩💻
    👩💻
    👨💻
    👩💻
    👨💻
    👨💻
    👨💻
    👨💻
    👩💻
    👩💻

    View Slide

  6. @marcoGomier
    🧩 Reusable modules


    🤓 Simplify development


    🧑💻 Split responsibilities & clarify ownership


    🚀 Improved build time
    Modularization in a nutshell

    View Slide

  7. @marcoGomier
    There is not a "right way"
    Disclaimer:

    View Slide

  8. @marcoGomier
    #1 Decide your
    architecture

    View Slide

  9. @marcoGomier
    Standard module layers
    App
    Features
    Libraries

    View Slide

  10. :app
    :shell
    :features:feat1
    :features:feat1-contract
    :libraries:lib1 :libraries:lib2
    :features:feat2
    :features:feat2-contract
    :features
    :libraries
    :libraries:lib3 :libraries:lib4
    TIER module layers

    View Slide

  11. :app
    :shell
    :features:feat1
    :features:feat1-contract
    :libraries:lib1 :libraries:lib2
    :features:feat2
    :features:feat2-contract
    :features
    :libraries
    :libraries:lib3 :libraries:lib4
    TIER module layers

    View Slide

  12. @marcoGomier
    Shell
    ● Shared core common code


    ● Should not be a new monolith with a different name 😅


    ● Not depend on upper layers
    :shell

    View Slide

  13. :app
    :features:feat1
    :features:feat1-contract
    :features:feat2
    :features:feat2-contract
    :features
    TIER module layers
    :shell
    :libraries:lib1 :libraries:lib2
    :libraries
    :libraries:lib3 :libraries:lib4

    View Slide

  14. @marcoGomier
    Libraries
    ● Small reusable components


    ● Can depend on other libraries or shell
    :libraries:lib1

    View Slide

  15. :app
    TIER module layers
    :shell
    :features:feat1
    :features:feat1-contract
    :libraries:lib1 :libraries:lib2
    :features:feat2
    :features:feat2-contract
    :features
    :libraries
    :libraries:lib3 :libraries:lib4

    View Slide

  16. @marcoGomier
    Features
    :features:feat1
    :features:feat1-contract
    ● A specific feature, could be user-facing or not


    ● Every feature has an implementation and

    a contract module

    View Slide

  17. @marcoGomier
    :features:feat1-contract
    Feature: contract
    ● A contract for the outside world


    ● What a feature can do and how it can be launched


    ● Contains Interfaces and Data Classes


    ● Can depend on libraries or shell
    @marcoGomier
    :features:location-contract
    interface LocationUseCase {


    fun getLocation(): Observable


    fun getLatestLocation(): Single


    fun fetchLocation(): Single


    }
    data class Location(


    val latitude: Double,


    val longitude: Double,


    )

    View Slide

  18. @marcoGomier
    Feature: implementation :features:feat1
    ● The implementation of the contract


    ● Has a dependency on its contract


    ● Can depend on other feature contracts, libraries or shell


    ● Invisible for other modules (except for :app)
    @marcoGomier
    :features:location internal class LocationUseCaseImpl(


    ...


    ) : LocationUseCase {


    override fun getLocation(): Observable {


    ...


    }

    ...


    }

    View Slide

  19. @marcoGomier
    Feature structure
    :features:feat1
    :features:feat1-contract
    :features:feat2
    :features:feat2-contract

    View Slide

  20. @marcoGomier
    Why?
    ● Low coupling: avoid knowledge of inner workings.


    ● Risk of need to import lot of transitive dependencies, when a module
    depends on many others


    ● Avoid increasing incremental build time

    View Slide

  21. :app
    :shell
    :features:feat1
    :features:feat1-contract
    :libraries:lib1 :libraries:lib2
    :features:feat2
    :features:feat2-contract
    :features
    :libraries
    :libraries:lib3 :libraries:lib4
    TIER module layers

    View Slide

  22. @marcoGomier
    App
    ● App initialization


    ● Dependency Injection setup


    ● Link all the module together


    ● It should be as slim as possible
    :app

    View Slide

  23. :app
    :shell
    :features:feat1
    :features:feat1-contract
    :libraries:lib1 :libraries:lib2
    :features:feat2
    :features:feat2-contract
    :features
    :libraries
    :libraries:lib3 :libraries:lib4
    TIER module layers

    View Slide

  24. :app
    :features:feat1
    :features:feat1-contract
    :legacy
    :libraries:lib1 :libraries:lib2
    :features:feat2
    :features:feat2-contract
    :features
    :libraries
    :libraries:lib3 :libraries:lib4
    :shell
    TIER module layers

    View Slide

  25. @marcoGomier
    Legacy
    ● A huge module with the non-modularized codebase


    ● Extract future features from here


    ● Try to not put any new code in it


    ● Can depend on feature contracts, libraries or shell
    :legacy

    View Slide

  26. @marcoGomier
    How to enforce this structure?
    ● Documentation


    ● “Computer says no” 🤖

    View Slide

  27. @marcoGomier
    #2 Start doing
    things

    View Slide

  28. @marcoGomier
    Starting point
    ● Select a feature to start with


    ● Can be a feature with UI or without UI


    ● A non UI feature will be easier: less things to figure out (spoiler:
    navigation)


    ● Extract Ride Handling to :features:ride

    View Slide

  29. @marcoGomier
    Find shared deps → Dependecies Analysis

    View Slide

  30. @marcoGomier

    View Slide

  31. @marcoGomier
    ● Figure out shared dependencies


    ● Move them to :shell


    ● Extract the Ride Handling into a new module
    Start modularizing

    View Slide

  32. @marcoGomier
    🎉

    View Slide

  33. :app
    :features:ride
    :features:ride-contract
    :features
    :shell

    View Slide

  34. @marcoGomier
    ● Move the remaing code from app to the legacy module


    ● Keep moving shared dependencies to shell
    Legacy code

    View Slide

  35. :app
    :features:ride
    :features:ride-contract
    :features
    :shell

    View Slide

  36. :app
    :features:ride
    :features:ride-contract
    :features
    :shell
    :legacy

    View Slide

  37. @marcoGomier
    Extract UI Features
    ● Time to extract a UI feature


    ● How to navigate between feature modules?


    ● 🚧 ⛔

    View Slide

  38. @marcoGomier
    #3 Rethink
    Navigation

    View Slide

  39. @marcoGomier
    Current Navigation Status
    ● Jetpack Navigation


    ● Every “feature” has its own navigation graph


    ● Not a single Activity Architecture


    ● Mostly every “feature” has its own hosting Activity

    View Slide

  40. @marcoGomier
    ShopActivity

    View Slide

  41. @marcoGomier
    ShopActivity
    ArticleList

    View Slide

  42. @marcoGomier
    ShopActivity
    ArticleList ArticleDetail

    View Slide

  43. @marcoGomier
    ShopActivity
    ArticleList ArticleDetail PurchaseSuccess

    View Slide

  44. @marcoGomier
    ShopActivity
    ArticleList ArticleDetail PurchaseSuccess
    :features:shop

    View Slide

  45. @marcoGomier
    :features:shop
    ShopActivity
    ArticleList ArticleDetail PurchaseSuccess
    :features:map
    MapActivity
    Map ShopActivity
    Start a feature from
    another module


    View Slide

  46. @marcoGomier
    :features:map
    MapActivity
    Map QRScannerActivity
    :features:qr-scanner
    QRScannerActivity
    Intro Scan
    Start a feature from
    another module and
    get a result back


    View Slide

  47. @marcoGomier
    :features:customersupport
    CustomerSupport
    Add a Fragment in
    the current flow


    ShopActivity
    ArticleList ArticleDetail CustomerSupport
    :features:shop

    View Slide

  48. @marcoGomier
    Some issues:
    ● Start a feature from another module

    ⛔ Deeplink with a single activity that includes all the graphs


    ● Start a feature and get a result back

    ⛔ Not out-of-the-box with Jetpack Navigation


    ● Add a Fragment in the current navigation graph

    ⛔ Not out-of-the-box with Jetpack Navigation

    View Slide

  49. @marcoGomier
    Fix the issues:
    ● Start and end a feature module

    Use the contract!

    View Slide

  50. @marcoGomier
    Start and end a feature module
    :features:shop-contract
    interface ShopContract {


    fun launch(fragment: Fragment)


    }
    :features:shop
    object ShopContractImpl : ShopContract {


    override fun launch(fragment: Fragment) {


    fragment.startActivity(


    Intent(


    fragment.requireContext(),


    ShopActivity::class.java,


    )


    )


    fragment.activity?.overridePendingTransition(


    R.anim.proceeding_enter,


    R.anim.proceeding_exit,


    )


    }


    }

    View Slide

  51. @marcoGomier
    Fix the issues:
    ● Start and end a feature module

    Use the contract!


    ● Start a feature module and get a result back

    Use the contract!

    View Slide

  52. @marcoGomier
    Start and end a feature module
    :features:qrscanner-contract
    interface QRScannerContract {


    fun launch(


    fragment: Fragment,


    onResultOk: () -> Unit,


    ): ActivityResultLauncher


    }
    :features:qrscanner
    object QRScannerContractImpl : QRScannerContract {


    override fun launch(fragment: Fragment, onResultOk: () -> Unit) {


    val launcher = fragment.registerForActivityResult(


    StartActivityForResult()


    ) { result ->


    if (result.resultCode == Activity.RESULT_OK) {


    onResultOk()


    }


    // Handle error


    }


    launcher.launch(


    Intent(


    fragment.requireContext(),


    PackagesActivity::class.java,


    )


    )


    }


    }

    View Slide

  53. @marcoGomier
    Fix the issues:
    ● Start and end a feature module

    Use the contract!


    ● Start a feature module and get a result back

    Use the contract!


    ● Add a Fragment in the current navigation graph

    Use the contract!

    View Slide

  54. @marcoGomier
    Start and end a feature module
    :features:qrscanner-contract
    interface ContactSupportContract {


    fun newInstance(


    scopeId: String,


    ): Fragment


    }
    :features:qrscanner
    object ContactSupportContractImpl : ContactSupportContract {


    override fun newInstance(scopeId: String): Fragment {


    return ContactSupportFragment.newInstance(scopeId)


    }


    }

    View Slide

  55. @marcoGomier
    Fix the issues:
    ● Start and end a feature module

    Use the contract!


    ● Start a feature module and get a result back

    Use the contract!


    ● Add a Fragment in the current navigation graph

    Use the contract!

    But how to handle navigation? 🤔

    View Slide

  56. @marcoGomier
    Introducing Router 🧭
    ● A module expose a Router interface for each
    subcomponent

    View Slide

  57. @marcoGomier
    Exposed Feature Router
    interface ContactSupportRouter {


    fun onContactSupportCallClick(phoneNumber: String)


    fun onContactSupportBackClick()


    }
    :features-contactsupport-contract

    View Slide

  58. @marcoGomier
    Introducing Router 🧭
    ● A module exposes a Router interface for each
    subcomponent


    ● A module has an internal Router to manage navigation
    that can also implement subcomponents’ Router

    View Slide

  59. @marcoGomier
    Internal Module Router
    internal class FeatureRouter : ContactSupportRouter, AnotherRouter {


    private val navigator = SingleFiringRelay()


    fun observe(): Observable = navigator.hide()


    fun goToMap() {


    navigator.accept(FeatureNavigationAction.OpenMap)


    }




    override fun onContactSupportCallClick(phoneNumber: String) {


    navigator.accept(FeatureNavigationAction.OpenPhoneDialer(phoneNumber))


    }


    override fun onContactSupportBackClick() {


    navigator.accept(FeatureNavigationAction.GoBack)


    }




    // Other routers’ implementation


    }

    View Slide

  60. @marcoGomier
    Internal Module Router
    internal class FeatureRouter : ContactSupportRouter, AnotherRouter {


    private val navigator = SingleFiringRelay()


    fun observe(): Observable = navigator.hide()


    fun goToMap() {


    navigator.accept(FeatureNavigationAction.OpenMap)


    }




    override fun onContactSupportCallClick(phoneNumber: String) {


    navigator.accept(FeatureNavigationAction.OpenPhoneDialer(phoneNumber))


    }


    override fun onContactSupportBackClick() {


    navigator.accept(FeatureNavigationAction.GoBack)


    }




    // Other routers’ implementation


    }

    View Slide

  61. @marcoGomier
    Internal Module Router
    internal class FeatureRouter : ContactSupportRouter, AnotherRouter {


    private val navigator = SingleFiringRelay()


    fun observe(): Observable = navigator.hide()


    fun goToMap() {


    navigator.accept(FeatureNavigationAction.OpenMap)


    }




    override fun onContactSupportCallClick(phoneNumber: String) {


    navigator.accept(FeatureNavigationAction.OpenPhoneDialer(phoneNumber))


    }


    override fun onContactSupportBackClick() {


    navigator.accept(FeatureNavigationAction.GoBack)


    }




    // Other routers’ implementation


    }

    View Slide

  62. @marcoGomier
    Internal Module Router
    internal class FeatureRouter : ContactSupportRouter, AnotherRouter {


    private val navigator = SingleFiringRelay()


    fun observe(): Observable = navigator.hide()


    fun goToMap() {


    navigator.accept(FeatureNavigationAction.OpenMap)


    }




    override fun onContactSupportCallClick(phoneNumber: String) {


    navigator.accept(FeatureNavigationAction.OpenPhoneDialer(phoneNumber))


    }


    override fun onContactSupportBackClick() {


    navigator.accept(FeatureNavigationAction.GoBack)


    }




    // Other routers’ implementation


    }

    View Slide

  63. @marcoGomier
    SingleFiringRelay
    internal sealed class FeatureNavigationAction {


    data class OpenPhoneDialer(val phoneNumber: String) : FeatureNavigationAction()


    object GoBack : FeatureNavigationAction()


    object OpenMap : FeatureNavigationAction()


    }
    @marcoGomier
    private val navigator = SingleFiringRelay()
    ● An RX Relay that works similarly to SingleLiveEvent


    ● If it doesn’t have any subscribers, it emits to the first new one


    ● If it already has subscribers, it doesn’t re-emit to new ones

    View Slide

  64. @marcoGomier
    New Navigation Approach
    ● Every module manages the navigation internally with the internal
    Router


    ● A feature module subcomponent can be integrated into another
    module by implementing its Router


    ● DI scoping is used to get the right Router implementation


    ● Navigation is handled “manually” and not with Jetpack Navigation


    ● Changes can be gradually introduced


    ● The navigation logic can be unit-tested

    View Slide

  65. @marcoGomier
    #4 Improve
    Gradle Setup

    View Slide

  66. @marcoGomier
    Dependency Management
    ● Some version numbers were hardcoded


    ● Switch to Gradle Version Catalog for managing dependencies
    [versions]


    io-reactivex-rxjava2-rxandroid = "2.1.1"


    io-reactivex-rxjava2-rxkotlin = "2.4.0"


    com-jakewharton-rxrelay = "2.1.1"


    [libraries]


    com-jakewharton-rxrelay = { module = "com.jakewharton.rxrelay2:rxrelay", version.ref = "com-jakewharton-rxrelay" }


    io-reactivex-rxjava2-rxandroid = { module = "io.reactivex.rxjava2:rxandroid", version.ref = "io-reactivex-rxjava2-rxandroid" }


    io-reactivex-rxjava2-rxkotlin = { module = "io.reactivex.rxjava2:rxkotlin", version.ref = "io-reactivex-rxjava2-rxkotlin" }


    [bundles]


    rxjava2 = ["io-reactivex-rxjava2-rxandroid", "io-reactivex-rxjava2-rxkotlin", "com-jakewharton-rxrelay"]


    @marcoGomier
    gradle/libs.versions.toml

    View Slide

  67. @marcoGomier
    Dependency Management
    ● Some version numbers were hardcoded


    ● Switch to Gradle Version Catalog for managing dependencies


    ● Bundles are awesome
    [bundles]


    feature-common-deps = ["androidx-appcompat", "androidx-constraintlayout", "androidx-recyclerview"]


    feature-testing = [“junit”, "androidx-io-mockk"]
    @marcoGomier
    gradle/libs.versions.toml

    View Slide

  68. @marcoGomier
    Module Configuration → buildSrc
    fun configureAndroidModule(


    project: Project,


    isDataBindingEnabled: Boolean,


    isComposeEnabled: Boolean = false,


    ) = project.libraryExtension.run {


    val javaSourceVersion = project.rootProject.extra.get("compile_source_compatibility") as JavaVersion


    val javaTargetVersion = project.rootProject.extra.get("compile_target_compatibility") as JavaVersion


    compileSdkVersion(project.ANDROID_COMPILE_SDK_VERSION)


    defaultConfig {


    minSdk = project.ANDROID_MIN_SDK_VERSION


    targetSdk = project.ANDROID_TARGET_SDK_VERSION


    testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"


    consumerProguardFiles("consumer-rules.pro")


    }


    ...




    }

    View Slide

  69. @marcoGomier
    Module Configuration → build.gradle
    plugins {


    id 'com.android.library'


    id 'kotlin-android'


    }


    ModuleConfig.configureAndroidModule(project)


    dependencies {


    ...


    }

    View Slide

  70. @marcoGomier
    Module Configuration → build.gradle
    plugins {


    id 'com.tier.android.library'


    id 'kotlin-android'


    }


    dependencies {


    ...


    }

    View Slide

  71. @marcoGomier
    More 🤖
    ● CookieCutter template to create a new feature or library module.


    ● Automatically add custom project configuration

    View Slide

  72. @marcoGomier
    #5 Restart doing
    things

    View Slide

  73. @marcoGomier
    Restarting point
    ● Now a UI feature can be extracted


    ● New UI feature module with the new Navigation 🎉


    ● 🚧 ⛔

    View Slide

  74. @marcoGomier
    Utility modules
    ● More utility modules are required for more complex UI
    feature modules: (testing tools, feature-flags, etc)


    ● Time for another dependency analysis


    ● Resume Feature UI modularization after having (almost) all
    the utilities ready

    View Slide

  75. @marcoGomier
    #6 Conclusions

    View Slide

  76. @marcoGomier
    July 2021
    Timeline
    Architecture
    Decisions

    View Slide

  77. @marcoGomier
    July 2021
    Timeline
    Nov 2021
    Sept 2021
    Aug 2021
    Navigation
    discussion
    :features:ride
    creation
    :shell 

    creation
    Architecture
    Decisions
    Feb 2022 May 2022
    Apr 2022
    March 2022
    Ready for bigger
    UI module
    More utility
    modules
    First UI feature
    module
    Gradle
    improvements
    ● Took almost a year to have everything in place


    ● Every 6 weeks, 2 weeks of “cooldown” → big chunk of planning and work


    ● Some work happens during the 6 weeks
    Modularization

    ongoing
    Now

    View Slide

  78. @marcoGomier
    Conclusions
    ● A long and difficult journey but totally worth it


    ● There’s no perfect recipe


    ● Don't be afraid to fail


    ● Don’t rush it


    ● Don’t refactor
    You will have a chance to find “hidden pearls” of your project 😅

    View Slide

  79. Resources
    ● https://developer.android.com/guide/navigation/navigation-multi-module


    ● https://www.droidcon.com/2019/11/15/android-at-scale-square/


    ● https://medium.com/inside-aircall/why-did-we-move-away-from-navigation-component-
    f2160f7c3f4b


    ● https://github.com/slackhq/slack-gradle-plugin


    ● https://github.com/cookiecutter/cookiecutter


    ● https://docs.gradle.org/current/userguide/platforms.html


    ● https://github.com/android/nowinandroid/blob/main/docs/ModularizationLearningJourney.md


    ● https://slack.engineering/stabilize-modularize-modernize-scaling-slacks-mobile-codebases/


    ● https://slack.engineering/stabilize-modularize-modernize-scaling-slacks-mobile-codebases-2/


    ● https://www.youtube.com/watch?v=K65M6jpyN4Y


    ● https://developer.android.com/topic/modularization

    View Slide

  80. @marcoGomier
    Marco Gomiero
    👨💻 Senior Android Engineer @ TIER

    Google Developer Expert for Kotlin
    Twitter: @marcoGomier

    Github: prof18

    Website: marcogomiero.com
    Thank you!

    View Slide