Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Forging the path from monolith to multi-module app | DevFest Munich

Forging the path from monolith to multi-module app | DevFest Munich

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 28, 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 full-size 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 full-size slide

  3. @marcoGomier
    🧑💻 👩💻

    View full-size slide

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

    View full-size slide

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

    View full-size slide

  6. @marcoGomier
    🧩 Reusable modules


    🤓 Simplify development


    🧑💻 Split responsibilities & clarify ownership


    🚀 Improved build time
    Modularization in a nutshell

    View full-size slide

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

    View full-size slide

  8. @marcoGomier
    #1 Decide your
    architecture

    View full-size slide

  9. @marcoGomier
    Standard module layers
    App
    Features
    Libraries

    View full-size 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 full-size 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 full-size 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 full-size 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 full-size slide

  14. @marcoGomier
    Libraries
    ● Small reusable components


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

    View full-size 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 full-size 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 full-size 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 full-size 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 full-size slide

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

    View full-size 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 full-size 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 full-size slide

  22. @marcoGomier
    App
    ● App initialization


    ● Dependency Injection setup


    ● Link all the module together


    ● It should be as slim as possible
    :app

    View full-size 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 full-size 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 full-size 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 full-size slide

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


    ● “Computer says no” 🤖

    View full-size slide

  27. @marcoGomier
    #2 Start doing
    things

    View full-size 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 full-size slide

  29. @marcoGomier
    Find shared deps → Dependecies Analysis

    View full-size slide

  30. @marcoGomier

    View full-size slide

  31. @marcoGomier
    ● Figure out shared dependencies


    ● Move them to :shell


    ● Extract the Ride Handling into a new module
    Start modularizing

    View full-size slide

  32. @marcoGomier
    🎉

    View full-size slide

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

    View full-size slide

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


    ● Keep moving shared dependencies to shell
    Legacy code

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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


    ● How to navigate between feature modules?


    ● 🚧 ⛔

    View full-size slide

  38. @marcoGomier
    #3 Rethink
    Navigation

    View full-size 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 full-size slide

  40. @marcoGomier
    ShopActivity

    View full-size slide

  41. @marcoGomier
    ShopActivity
    ArticleList

    View full-size slide

  42. @marcoGomier
    ShopActivity
    ArticleList ArticleDetail

    View full-size slide

  43. @marcoGomier
    ShopActivity
    ArticleList ArticleDetail PurchaseSuccess

    View full-size slide

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

    View full-size slide

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


    View full-size 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 full-size slide

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


    ShopActivity
    ArticleList ArticleDetail CustomerSupport
    :features:shop

    View full-size 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 full-size slide

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

    Use the contract!

    View full-size 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 full-size 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 full-size 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 full-size 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 full-size 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 full-size 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 full-size slide

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

    View full-size slide

  57. @marcoGomier
    Exposed Feature Router
    interface ContactSupportRouter {


    fun onContactSupportCallClick(phoneNumber: String)


    fun onContactSupportBackClick()


    }
    :features-contactsupport-contract

    View full-size 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 full-size 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 full-size 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 full-size 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 full-size 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 full-size 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 full-size 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 full-size slide

  65. @marcoGomier
    #4 Improve
    Gradle Setup

    View full-size 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 full-size 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 full-size 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 full-size slide

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


    id 'com.android.library'


    id 'kotlin-android'


    }


    ModuleConfig.configureAndroidModule(project)


    dependencies {


    ...


    }

    View full-size slide

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


    id 'com.tier.android.library'


    id 'kotlin-android'


    }


    dependencies {


    ...


    }

    View full-size slide

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


    ● Automatically add custom project configuration

    View full-size slide

  72. @marcoGomier
    #5 Restart doing
    things

    View full-size slide

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


    ● New UI feature module with the new Navigation 🎉


    ● 🚧 ⛔

    View full-size 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 full-size slide

  75. @marcoGomier
    #6 Conclusions

    View full-size slide

  76. @marcoGomier
    July 2021
    Timeline
    Architecture
    Decisions

    View full-size 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

    View full-size 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 full-size 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 full-size slide

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

    Google Developer Expert for Kotlin
    Twitter: @marcoGomier

    Github: prof18

    Website: marcogomiero.com
    Thank you!

    View full-size slide