Modularization has become an essential part of Mobile Development at scale, however it isn’t simple. One of the goals of modularizing effectively is keeping modules independent and the module graph flat. Using plugin interfaces across the modules are one of the most effective techniques to achieve this and get the desired benefits. Let’s have a look at how to use it.
Pic by Tania MT with ❤
The problem
We often face a common scenario during modularization — a single module needs to depend on many other features and compose them together. Imagine application startup initializing different parts of the app — in this situation the startup code has to access many features.
The typical approach is to make our feature dependent on all the relevant modules to be able to reference the code. This might be okay if we depend on very few modules, however it causes several issues once the dependencies grow.
- Coupling: One feature depends on many others.
- Complexity: Aggregation of dependencies in one module.
- Modules graph: Once we have more of these aggregating features, the module graph starts to become much more complex and we can end up with a web of dependencies that’s hard to manage (why it matters).
The problem: the height of the graph grows and modules are tightly coupled.
Use cases for plugins
There are many situations in which you need many modules to contribute towards common logic, where plugins offer a solution. Some examples:
- App startup: Many modules need some form of initialization and need to hook into the
Application.onCreate
method or a different point in the lifecycle. - Login or Logout: Different parts of the app need to be set up or cleaned up when the user logs in or out.
- Handling Push: Typically one module is the entry point for receiving push messages, however multiple modules may wish to receive different types of messages.
- Handling deep links: Similarly as push, one module serves as an entry point whilst many modules need to handle different deep links.
- Feature Toggles: A module might want to declare different toggles, however the app might have a single toggle endpoint for all modules.
- HTTP headers: Different modules want to setup common headers for the application.
Solution
The general solution involves a collection of plugins which are composed across modules, collected manually or via dependency injection such as Dagger. We will use a login plugin using Dagger as an example, however the same concept can be implemented via other approaches of passing dependencies.
Plugin pattern— login example.
- A feature module defines a public plugin interface and expects to receive a collection of instances, implementing the interface.
Let’s imagine a:login-api
module offering an interface which will be used across many modules.
interface LoginPlugin { fun onLogin(user: User) fun onLogout() }
2. The :login
module consumes the plugins as a collection.
class LoginLogic @Inject constructor( val loginPlugins: @JvmSuppressWildcards Set<LoginPlugin> ) { ... fun onLogin(user: User) { loginPlugins.forEach { plugin -> plugin.onLogin(user) } } fun onLogout() { loginPlugins.forEach { plugin -> plugin.onLogout() } } ... }
3. Other modules depend on :login-api
and implement their own plugins. For example the :user-theme
module changes the theme based on user settings.
class UserThemePlugin @Inject constructor() : LoginPlugin { override fun onLogin(user: User) { setPreferredTheme(user.prefferedTheme) } override fun onLogout() { setDefaultTheme() } }
Job Offers
4. The last piece is connecting things together, which could be done using Dagger Multibingings. The :user-theme
module uses the @IntoSet
annotation to contribute to the Set<LoginPlugin>
. All the modules with bindings are then composed within the :app
module.
@Binds @IntoSet fun bindUserThemePlugin(plugin: UserThemePlugin): LoginPlugin
The Set<LoginPlugin>
will now contain the UserThemePlugin
instance and the LoginLogic
will start calling the relevant methods — UserThemePlugin
“plugged in” the login.
Why is this powerful?
- The
:login
module is completely decoupled from the rest of the application and doesn’t know anything about theming. - We can add or remove logic based on which modules we include. This becomes useful when you have multiple apps, want to have apps that only include part of the codebase for fast development or for instant apps.
- We can provide different plugins for our tests, allowing us to verify logic.
- We can achieve completely isolated features without modification out of the module — only plugins.
Examples
This concept is definitely not new and we can find many existing examples.
- Interceptors in OkHttp behave like plugins with a chain of responsibility. Numerous other libraries extend OkHttp by providing their own interceptors.
- ActivityLifecycleCallbacks or FragmentLifecycleCallbacks are a powerful way to add new logic to your app without modifying any existing feature and are used for example by Firebase In-App messaging or Cloud Messaging or by Context free navigation.
- PushActionCommand can show how to delegate push and use the
@IntoMap
annotation to select between different implementations. - OnAppCreate shows app startup logic, in many cases combined with ActivityLifecycleCallbacks providing pluggable features like performance monitoring or logging.
- LinkLauncher demonstrates different modules hooking into deep link navigation.
Challenges
Nothing is perfect and plugin interfaces also have their own downsides, which we need to keep in mind to prevent being impacted by them.
- Required plugins missing: It can be easy to forget to add a plugin the app depends on or to bind it incorrectly. The app will compile and run just fine but we would lose features, leading to buggy behavior.
The solution is to use integration testing, verifying the features behave as expected. - Error handling: It might be tempting to simply wrap the plugin’s execution in a try-catch, but we cannot know what logic within the plugin abstraction throws errors. Incomplete execution of the plugins can lead to inconsistent state.
The solution depends on the use case, but the general recommendation is for the concrete plugin to handle the throwable and recover itself. If the plugin cannot handle the error itself then we should just propagate the throwable and not try to handle it at the level of a collection of plugins. - Dependencies between plugins: A group of plugins may need to run in a certain order and implicit dependencies appear.
The solution is to set a priority and then after injecting using the@IntoMap
Dagger annotation, we can sort the plugins by priority or using an enum of known implementations as a key for the priority. This would propagate some implementation knowledge to the plugin declaration as a tradeoff, but it can be effective when an exact ordering of plugins is required. - Low performing plugins: Execution of the plugins could became slow if there are many plugins or if one plugin is doing something expensive. Since the owner of the plugin interface does not have control and visibility over the contributing plugins’ implementations.
The solution is to monitor per plugin execution metrics in critical parts like app startup to identify potential bottlenecks.
Enjoy the plugin world
Modularization and flattening the module graph is hard and as such requires introducing certain modularization patterns. The plugin-based approach is one such pattern and applying it can help bring the desired benefits of modularization and a highly modular codebase.
Do you use plugins in your project or other modularization approaches? Let others know in the comments.
Happy modularizing!
Thanks Andrew Lord for proof reading and editorial.
This article was previously published on proandroiddev.com