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

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

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

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

July 07, 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
  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
  3. @marcoGomier 🧑💻 👩💻

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

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

    👩💻 👨💻 👨💻 👨💻 👨💻 👩💻 👩💻
  6. @marcoGomier 🧩 Reusable modules 🤓 Simplify development 🧑💻 Split responsibilities

    & clarify ownership 🚀 Improved build time Modularization in a nutshell
  7. @marcoGomier There is not a "right way" Disclaimer:

  8. @marcoGomier #1 Decide your architecture

  9. @marcoGomier Standard module layers App Features Libraries

  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
  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
  12. @marcoGomier Shell • Shared core common code • Should not

    be a new monolith with a different name 😅 • Not depend on upper layers :shell
  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
  14. @marcoGomier Libraries • Small reusable components • Can depend on

    other libraries or shell :libraries:lib1
  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
  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
  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<Location> fun getLatestLocation(): Single<Location> fun fetchLocation(): Single<Location> } data class Location( val latitude: Double, val longitude: Double, )
  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<Location> { ... } 
 ... }
  19. @marcoGomier Feature structure :features:feat1 :features:feat1-contract :features:feat2 :features:feat2-contract

  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
  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
  22. @marcoGomier App • App initialization • Dependency Injection setup •

    Link all the module together • It should be as slim as possible :app
  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
  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
  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
  26. @marcoGomier How to enforce this structure? • Documentation • “Computer

    says no” 🤖
  27. @marcoGomier #2 Start doing things

  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
  29. @marcoGomier https://www.droidcon.com/2021/11/10/clean-up-state-handling-with-a-state-machine/

  30. @marcoGomier Find shared deps → Dependecies Analysis

  31. @marcoGomier

  32. @marcoGomier • Figure out shared dependencies • Move them to

    :shell • Extract the Ride Handling into a new module Start modularizing
  33. @marcoGomier 🎉

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

  35. @marcoGomier • Move the remaing code from app to the

    legacy module • Keep moving shared dependencies to shell Legacy code
  36. :app :features:ride :features:ride-contract :features :shell

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

  38. @marcoGomier Extract UI Features • Time to extract a UI

    feature • How to navigate between feature modules? • 🚧 ⛔
  39. @marcoGomier #3 Rethink Navigation

  40. @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
  41. @marcoGomier ShopActivity

  42. @marcoGomier ShopActivity ArticleList

  43. @marcoGomier ShopActivity ArticleList ArticleDetail

  44. @marcoGomier ShopActivity ArticleList ArticleDetail PurchaseSuccess

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

  46. @marcoGomier :features:shop ShopActivity ArticleList ArticleDetail PurchaseSuccess :features:map MapActivity Map ShopActivity

    Start a feature from another module
  47. @marcoGomier :features:map MapActivity Map QRScannerActivity :features:qr-scanner QRScannerActivity Intro Scan Start

    a feature from another module and get a result back
  48. @marcoGomier :features:customersupport CustomerSupport Add a Fragment in the current flow

    ShopActivity ArticleList ArticleDetail CustomerSupport :features:shop
  49. @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
  50. @marcoGomier Fix the issues: • Start and end a feature

    module 
 Use the contract!
  51. @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, ) } }
  52. @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!
  53. @marcoGomier Start and end a feature module :features:qrscanner-contract interface QRScannerContract

    { fun launch( fragment: Fragment, onResultOk: () -> Unit, ): ActivityResultLauncher<Intent> } :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, ) ) } }
  54. @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!
  55. @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) } }
  56. @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? 🤔
  57. @marcoGomier Introducing Router 🧭 • A module expose a Router

    interface for each subcomponent
  58. @marcoGomier Exposed Feature Router interface ContactSupportRouter { fun onContactSupportCallClick(phoneNumber: String)

    fun onContactSupportBackClick() } :features-contactsupport-contract
  59. @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
  60. @marcoGomier Internal Module Router internal class FeatureRouter : ContactSupportRouter, AnotherRouter

    { private val navigator = SingleFiringRelay<FeatureNavigationAction>() fun observe(): Observable<FeatureNavigationAction> = 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 }
  61. @marcoGomier Internal Module Router internal class FeatureRouter : ContactSupportRouter, AnotherRouter

    { private val navigator = SingleFiringRelay<FeatureNavigationAction>() fun observe(): Observable<FeatureNavigationAction> = 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 }
  62. @marcoGomier Internal Module Router internal class FeatureRouter : ContactSupportRouter, AnotherRouter

    { private val navigator = SingleFiringRelay<FeatureNavigationAction>() fun observe(): Observable<FeatureNavigationAction> = 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 }
  63. @marcoGomier Internal Module Router internal class FeatureRouter : ContactSupportRouter, AnotherRouter

    { private val navigator = SingleFiringRelay<FeatureNavigationAction>() fun observe(): Observable<FeatureNavigationAction> = 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 }
  64. @marcoGomier SingleFiringRelay internal sealed class FeatureNavigationAction { data class OpenPhoneDialer(val

    phoneNumber: String) : FeatureNavigationAction() object GoBack : FeatureNavigationAction() object OpenMap : FeatureNavigationAction() } @marcoGomier private val navigator = SingleFiringRelay<FeatureNavigationAction>() • 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
  65. @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
  66. @marcoGomier #4 Improve Gradle Setup

  67. @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
  68. @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
  69. @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") } ... }
  70. @marcoGomier Module Configuration → build.gradle plugins { id 'com.android.library' id

    'kotlin-android' } ModuleConfig.configureAndroidModule(project) dependencies { ... }
  71. @marcoGomier Module Configuration → build.gradle plugins { id 'com.tier.android.library' id

    'kotlin-android' } dependencies { ... }
  72. @marcoGomier More 🤖 • CookieCutter template to create a new

    feature or library module. • Automatically add custom project configuration
  73. @marcoGomier #5 Restart doing things

  74. @marcoGomier Restarting point • Now a UI feature can be

    extracted • New UI feature module with the new Navigation 🎉 • 🚧 ⛔
  75. @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
  76. @marcoGomier #6 Conclusions

  77. @marcoGomier July 2021 Timeline Architecture Decisions

  78. @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
  79. @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 😅
  80. 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://www.droidcon.com/2021/11/10/clean-up-state-handling-with-a-state-machine/
  81. @marcoGomier Marco Gomiero 👨💻 Senior Android Engineer @ TIER 


    Google Developer Expert for Kotlin Twitter: @marcoGomier 
 Github: prof18 
 Website: marcogomiero.com Thank you!