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

Forging the path from monolith to multi-module ...

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 🧩 Reusable modules 🤓 Simplify development 🧑💻 Split responsibilities

    & clarify ownership 🚀 Improved build time Modularization in a nutshell
  6. @marcoGomier Shell • Shared core common code • Should not

    be a new monolith with a different name 😅 • Not depend on upper layers :shell
  7. :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
  8. @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
  9. @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, )
  10. @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> { ... } 
 ... }
  11. @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
  12. @marcoGomier App • App initialization • Dependency Injection setup •

    Link all the module together • It should be as slim as possible :app
  13. @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
  14. @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
  15. @marcoGomier • Figure out shared dependencies • Move them to

    :shell • Extract the Ride Handling into a new module Start modularizing
  16. @marcoGomier • Move the remaing code from app to the

    legacy module • Keep moving shared dependencies to shell Legacy code
  17. @marcoGomier Extract UI Features • Time to extract a UI

    feature • How to navigate between feature modules? • 🚧 ⛔
  18. @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
  19. @marcoGomier :features:customersupport CustomerSupport Add a Fragment in the current flow

    ShopActivity ArticleList ArticleDetail CustomerSupport :features:shop
  20. @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
  21. @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, ) } }
  22. @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!
  23. @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, ) ) } }
  24. @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!
  25. @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) } }
  26. @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? 🤔
  27. @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
  28. @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 }
  29. @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 }
  30. @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 }
  31. @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 }
  32. @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
  33. @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
  34. @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
  35. @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
  36. @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") } ... }
  37. @marcoGomier Module Configuration → build.gradle plugins { id 'com.android.library' id

    'kotlin-android' } ModuleConfig.configureAndroidModule(project) dependencies { ... }
  38. @marcoGomier More 🤖 • CookieCutter template to create a new

    feature or library module. • Automatically add custom project configuration
  39. @marcoGomier Restarting point • Now a UI feature can be

    extracted • New UI feature module with the new Navigation 🎉 • 🚧 ⛔
  40. @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
  41. @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
  42. @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 😅
  43. 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/
  44. @marcoGomier Marco Gomiero 👨💻 Senior Android Engineer @ TIER 


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