Bug 1824856 - add release-notify-testrail to treeherder. r=gbrown,releng-reviewers...
[gecko.git] / mobile / android / docs / rfcs / 0009-remove-interactors-and-controllers.md
blob9514154adbd4e49422349b13ba764c53ef855a58
1 ---
2 layout: page
3 title: Remove Interactors and Controllers
4 permalink: /rfc/0009-remove-interactors-and-controllers
5 ---
7 * Start date: 2022-03-29
8 * RFC PR: [1466](https://github.com/mozilla-mobile/firefox-android/pull/1466)
10 ## Summary
12 Now that Fenix has been fully migrated to rely on Android Component's `lib-state` library, Fenix's architecture can be further simplified by refactoring view components to directly use our Redux-like store, instead of going through various layers of `Interactor`s and `Controller`s.
14 ## Motivation
16 The `Interactor/Controller` types, as a design pattern, have recently had their usefulness and clarity come into question in reviews and conversations with some regularity. `Interactor`s in particular seem to often be an unnecessary abstraction layer, usually containing methods that call similarly named methods in a `Controller` that share a similar name to the interactor calling them. Interactors that are used solely to delegate to other classes will be referred to as 'passthrough' interactors throughout this document.
18 The [definition](../../fenix/docs/architecture-overview.md#interactor) of interactors in our architecture overview indicate that this is at least partially intentional: 'Called in response to a direct user action. Delegates to something else'. This is even referenced as a [limitation at the end of the overview](../../fenix/docs/architecture-overview.md#known-limitations).
20 Historically, the interactor/controller pattern evolved from a Presenter/Controller/View pattern that originated in Android Components. The underlying motivation of that pattern was to separate code presenting the state from the view (Presenters) and the code that updated the state from the view (Controllers).
22 Generally, the interactor/controller pattern is also intended to move business logic out of classes tied to the Android framework and into classes that are more testable, reusable, composable, digestible, and which handle a single responsibility. More reading on architecture goals is available [in our Architecture Decisions](../../fenix/docs/Architecture-Decisions.md#goals).
24 These goals are all met reasonably well by the existing pattern, but it also contributes to unnecessary class explosion, general code complexity, and confusion about responsibility for each architectural type. This becomes especially true when wondering how responsibility should be split between interactors and controllers. It seems that interactors are often included as a matter of precedent and not to necessarily facilitate all the goals mentioned above, and this has lead to a large amount passthrough interactors.
26 ### Proposal goals
28 1. Increase code comprehensibility
29 2. Have clear delineation of responsibility between architectural components
30 3. Retain ability to manage state outside of fragments/activities/etc
31 4. Ability for proposal to be adopted incrementally
33 ### Further contextual reading
35 An investigation was previously done to provide [further context](https://docs.google.com/document/d/1vwERcAW9_2LkcYENhnA5Kb-jT_nBTJNwnbD5m9zYSA4/edit#heading=h.jjoifpwhhxgk) on the current state of interactors. To summarize findings _in Fenix specifically_:
36 1. 29/36 concrete `Interactor` classes are strict passthrough interactors or are passthrough interactors that additionally record metrics.
37 2. Other interactors seem to have mixed responsibilities, including but not limited to:
38         - dispatching actions to `Store`s
39         - initiating navigation events
40         - delegating to `UseCase`s or directly to closures instead of `Controller`s.
42 ---
44 ## Guide-level explanation
46 The proposal is to remove interactors and controllers completely from the codebase. Their usages will be replaced with direct Store observations and direct action dispatches to those Stores. All state changes would be handled by reducers, and side-effects like telemetry or disk writes would be handled in middlewares.
48 This would address all the goals listed above:
49 1. Code comprehensibility should be improved by having a single architectural pattern. All business logic would be discoverable within `Reducer`s, and changes to `State` would be much more explicit.
50 2. Responsibility would be clearly delineated as all business logic would be handled by `Reducer`s and all side-effects by `Middleware`, instead of being scattered between those components as well as `Interactor`s, `Controller`s, and various utility classes.
51 3. `State` management would happen within `Reducer`s and would still accomplish the usual goals around testability.
52 4. Refactoring interactors/controllers to instead dispatch actions and react to state changes should be doable on a per-component basis.
54 Additionally, there are tons of prior art discussing the benefits of a unidirectional data flow, including the [Redux documentation](https://redux.js.org/tutorials/fundamentals/part-2-concepts-data-flow).
56 ---
58 ## Reference-level explanation
60 Throughout the app are examples of chains of interactors and controllers which lead to a method that dispatches events to a Store anyway. Simplifying this mental model should allow faster code iteration and code navigation.
62 For one example, here is the code path that happens when a user long-clicks a history item:
64 ```kotlin
65 // 1. in LibrarySiteItemView
66 setOnClickListener {
67     val selected = holder.selectedItems
68     when {
69         selected.isEmpty() -> interactor.open(item)
70         item in selected -> interactor.deselect(item)
71         // "select" is the path we are following
72         else -> interactor.select(item)
73     }
76 // 2. Clicking into `interactor.select` will lead to SelectionInteractor<T>.
77 // 3. Searching for implementers of that interface will lead us to another interface, `HistoryInteractor : SelectionInteractor<History>`
78 // 4. DefaultHistoryInteractor implements HistoryInteractor
79 override fun open(item: History) {
80     historyController.handleSelect(item)
83 // 5. Clicking into `handleSelect` leads to `HistoryController`
84 // 6. DefaultHistoryController implements `HistoryController::handleSelect`
85 override fun handleSelect(item: History) {
86     if (store.state.mode === HistoryFragmentState.Mode.Syncing) {
87         return
88     }
90     store.dispatch(HistoryFragmentAction.AddItemForRemoval(item))
93 // 7. reducer handles state update
94 private fun historyStateReducer(
95     state: HistoryFragmentState,
96     action: HistoryFragmentAction,
97 ): HistoryFragmentState {
98     return when (action) {
99         is HistoryFragmentAction.AddItemForRemoval ->
100             state.copy(mode = HistoryFragmentState.Mode.Editing(state.mode.selectedItems + action.item))
101         ...
102     }
106 Following this proposal, the above would change to something like:
108 ```kotlin
109 // 1. in LibrarySiteItemView
110 setOnClickListener {
111     when {
112         selected.isEmpty() -> store.dispatch(HistoryFragmentAction.AddItemForRemoval(item))
113         ...
114     }
117 // 2. reducer handles state
118 private fun historyStateReducer(
119     state: HistoryFragmentState,
120     action: HistoryFragmentAction,
121 ): HistoryFragmentState {
122     return when (action) {
123         is HistoryFragmentAction.AddItemForRemoval(item) -> when (state.mode) {
124             HistoryFragmentState.Mode.Syncing -> state
125             else -> state.copy(
126                 mode = HistoryFragmentState.Mode.Editing(state.mode.selectedItems + action.item)
127             )
128         }
129         ...
130     }
134 This reduces the number of layers in the code path. Note also that business logic around whether to update based on Sync status is incorporated locally in the Reducer. In this example, the details of observing state from the store are complicated by the additional view hierarchies required by recycler views, so they have been omitted for brevity.
138 ### Reacting to changes: state observations and side-effects in XML
140 Generally speaking, there are two opportunities to launch side-effects in a Redux pattern:
141 1. Reacting to a user-initiated action
142 2. Reacting to the result of a state change
144 The first will be covered in more detail below and is handled by middleware, where side-effects are started in reaction to some dispatched action.
146 The second requires a mechanism of observation for a Store's state, triggering some logic or work in response to state updates. On Android these observations are often started in the UI layer in order to tie them to lifecycle events. However, UI framework components like activities, fragments, and views are notoriously difficult to test and we want to avoid a situation where these classes become too large or complex.
148 There are already some existing mechanisms for doing this included in the lib state library:
150 1. `Fragment.consumeFrom` will setup a Store observation that is triggered on _every_ state update in the Store. The [HistoryFragment updating its view hierarchy with all state updates](https://github.com/mozilla-mobile/firefox-android/blob/910afa889ebc5222a1b9a877837de5c55244d441/fenix/app/src/main/java/org/mozilla/fenix/library/history/HistoryFragment.kt#L196) is one example.
151 2. `Flow.distinctUntilChanged` can be combined with things like `Fragment.consumeFlow` or `Store.flowScoped` for finer-grained state observations. This will allow reactions to specific state properties updates, like when the [HistorySearchDialogFragment changes the visibility of the AwesomeBar](https://github.com/mozilla-mobile/firefox-android/blob/910afa889ebc5222a1b9a877837de5c55244d441/fenix/app/src/main/java/org/mozilla/fenix/library/history/HistorySearchDialogFragment.kt#L189).
153 While both of these options are fine in many situations, they would both be difficult to setup to test. For a testable option, consider inheriting from [AbstractBinding](https://github.com/mozilla-mobile/firefox-android/blob/1cd69fec56725410af855eda923ab1afe99ae0b2/android-components/components/lib/state/src/main/java/mozilla/components/lib/state/helpers/AbstractBinding.kt#L23). This allows for defining a class that accepts dependencies and is isolated, making unit tests much easier. For some examples of that, see the [SelectedItemAdapterBinding](https://github.com/mozilla-mobile/firefox-android/blob/910afa889ebc5222a1b9a877837de5c55244d441/fenix/app/src/main/java/org/mozilla/fenix/tabstray/browser/BrowserTabsAdapter.kt#L61) or the [SwipeToDeleteBinding](https://github.com/mozilla-mobile/firefox-android/blob/910afa889ebc5222a1b9a877837de5c55244d441/fenix/app/src/main/java/org/mozilla/fenix/tabstray/browser/AbstractBrowserTrayList.kt#L35-L37) which are both used in the XML version of the Tabs Tray.
155 For more details, see the example refactor in the [example refactor section](#an-example-refactor-removing-interactors-and-controllers-from-historyfragment).
159 ### Moving into the future: using lib-state with Compose
161 Ideally, fragments or top-level views/Composables would register observers of their Store's state and send updates from those observers to their child Composables along with closures containing dispatches to those Stores. We are already close to being familiar with this pattern in some places. The following is a current example that has been edited for brevity and clarity:
163 ```kotlin
164 class PocketCategoriesViewHolder(
165     composeView: ComposeView,
166     viewLifecycleOwner: LifecycleOwner,
167     private val interactor: PocketStoriesInteractor,
168 ) : ComposeViewHolder(composeView, viewLifecycleOwner) {
170     @Composable
171     override fun Content() {
172         val categories = components.appStore
173             .observeAsComposableState { state -> state.pocketStoriesCategories }.value
174         val categoriesSelections = components.appStore
175             .observeAsComposableState { state -> state.pocketStoriesCategoriesSelections }.value
177         PocketTopics(
178             categoryColors = categoryColors,
179             categories = categories ?: emptyList(),
180             categoriesSelections = categoriesSelections ?: emptyList(),
181             onCategoryClick = interactor::onCategoryClicked,
182         )
183     }
187 Here, we already see a view registering Store observers appropriately. The only thing that would need to change is the line calling the interactor, `onCategoryClick = interactor::onCategoryClicked,`. Following this line leads down a similar chain of abstraction layers, and eventually leads to `DefaultPocketStoriesController::handleCategoryClicked`. This function contains business logic which would need to be moved to the reducer and telemetry side-effects that would need to be moved to a middleware, but the ultimate intent is to toggle the selected category. We skip many layers of indirection by dispatching that action directly from the view:
189 ```kotlin
190 class PocketCategoriesViewHolder(
191     composeView: ComposeView,
192     viewLifecycleOwner: LifecycleOwner,
193 ) : ComposeViewHolder(composeView, viewLifecycleOwner) {
195     @Composable
196     override fun Content() {
197         val categories = components.appStore
198             .observeAsComposableState { state -> state.pocketStoriesCategories }.value
199         val categoriesSelections = components.appStore
200             .observeAsComposableState { state -> state.pocketStoriesCategoriesSelections }.value
202         PocketTopics(
203             categoryColors = categoryColors,
204             categories = categories ?: emptyList(),
205             categoriesSelections = categoriesSelections ?: emptyList(),
206             onCategoryClick = { name ->
207                 components.appStore.dispatch(AppAction.TogglePocketStoriesCategory(name))
208             },
209         )
210     }
214 This should simplify the search for underlying logic by:
215 - moving business logic into an expected and consistent component (the reducer)
216 - moving side-effects into a consistent and centralized component (a middleware, which could even handle telemetry for the entire store)
220 ### Extending the example: separating state and side-effects
222 To demonstrate the bullets above, here is the method definition in the `DefaultPocketStoriesController` that currently handles the business logic initiated from the `interactor::onCategoryClicked` call above.
224 ```kotlin
225     override fun handleCategoryClick(categoryClicked: PocketRecommendedStoriesCategory) {
226         val initialCategoriesSelections = appStore.state.pocketStoriesCategoriesSelections
228         // First check whether the category is clicked to be deselected.
229         if (initialCategoriesSelections.map { it.name }.contains(categoryClicked.name)) {
230             appStore.dispatch(AppAction.DeselectPocketStoriesCategory(categoryClicked.name))
231             Pocket.homeRecsCategoryClicked.record(
232                 Pocket.HomeRecsCategoryClickedExtra(
233                     categoryName = categoryClicked.name,
234                     newState = "deselected",
235                     selectedTotal = initialCategoriesSelections.size.toString(),
236                 ),
237             )
238             return
239         }
241         // If a new category is clicked to be selected:
242         // Ensure the number of categories selected at a time is capped.
243         val oldestCategoryToDeselect =
244             if (initialCategoriesSelections.size == POCKET_CATEGORIES_SELECTED_AT_A_TIME_COUNT) {
245                 initialCategoriesSelections.minByOrNull { it.selectionTimestamp }
246             } else {
247                 null
248             }
249         oldestCategoryToDeselect?.let {
250             appStore.dispatch(AppAction.DeselectPocketStoriesCategory(it.name))
251         }
253         // Finally update the selection.
254         appStore.dispatch(AppAction.SelectPocketStoriesCategory(categoryClicked.name))
256         Pocket.homeRecsCategoryClicked.record(
257             Pocket.HomeRecsCategoryClickedExtra(
258                 categoryName = categoryClicked.name,
259                 newState = "selected",
260                 selectedTotal = initialCategoriesSelections.size.toString(),
261             ),
262         )
263     }
266 If we break this down into a pseudocode algorithm, we get the following steps:
267 1. Read the current state from the store
268 2. If the category is being deselected
269     1. Dispatch an action to update the state
270     2. Record a metric that the category was deselected
271     3. Return early
272 3. If the number of categories selected would be over max
273     1. Dispatch an action deselecting the oldest
274 4. Dispatch an action with the newly selected category
275 5. Record a metric that a category was selected
277 In order to transform this algorithm into an appropriate usage of the Redux pattern, we can separate this list into two parts: steps that have side-effects and steps which can be represented by pure state updates. In this case, only the steps that record metrics represent side-effects. This will mean that those steps should be moved into a middleware, and the rest can be moved into a reducer.
279 Here's how that might look:
280 ```kotlin
281 // add a new AppAction
282 data class TogglePocketStoriesCategory(val categoryName: String) : AppAction()
284 // add a case to handle it in a middleware. For example, let's put the following in MetricsMiddleware:
285 private fun handleAction(action: AppAction) = when (action) {
286     ...
287     is AppAction.TogglePocketStoriesCategory -> {
288         val currentCategoriesSelections = currentState.pocketStoriesCategoriesSelections.map { it.name }
289         // check if the category is being deselected
290         if (currentCategoriesSelections.contains(action.categoryName)) {
291             Pocket.homeRecsCategoryClicked.record(
292                 Pocket.HomeRecsCategoryClickedExtra(
293                     categoryName = action.categoryName,
294                     newState = "deselected",
295                     selectedTotal = currentCategoriesSelections.size.toString(),
296                 ),
297             )
298         } else {
299             Pocket.homeRecsCategoryClicked.record(
300                 Pocket.HomeRecsCategoryClickedExtra(
301                     categoryName = action.categoryName,
302                     newState = "selected",
303                     selectedTotal = currentCategoriesSelections.size.toString(),
304                 ),
305             )
306         }
307     }
311 // finally, we can shift all the state updating logic into the reducer
312 fun reduce(state: AppState, action: AppAction): AppState = when (action) {
313     ...
314     is AppAction.TogglePocketStoriesCategory -> {
315         val currentCategoriesSelections = state.pocketStoriesCategoriesSelections.map { it.name }
316         val updatedCategoriesState = when {
317             currentCategoriesSelections.contains(action.categoryName) -> {
318                 state.copy(
319                     pocketStoriesCategoriesSelections = state.pocketStoriesCategoriesSelections.filterNot {
320                         it.name == action.categoryName
321                     },
322                 )
323             }
324             currentCategoriesSelections.size == POCKET_CATEGORIES_SELECTED_AT_A_TIME_COUNT -> {
325                 state.copy(
326                     pocketStoriesCategoriesSelections = state.pocketStoriesCategoriesSelections
327                         .minusOldest()
328                         .plus(
329                             PocketRecommendedStoriesSelectedCategory(
330                                 name = action.categoryName,
331                             )
332                         ),
333                 )
334             }
335             else -> {
336                 state.copy(
337                     pocketStoriesCategoriesSelections =
338                     state.pocketStoriesCategoriesSelections +
339                             PocketRecommendedStoriesSelectedCategory(action.categoryName)
340                 )
341             }
342         }
343         // Selecting a category means the stories to be displayed needs to also be changed.
344         updatedCategoriesState.copy(
345             pocketStories = updatedCategoriesState.getFilteredStories(),
346         )
347     }
353 ### Interacting with the storage layer through middlewares
355 What if a feature requires some async data for its initial state, or needs to write changes to disk? There are no restrictions on running impure methods in middleware, so we can respond to an action that would make changes to the storage layer from the middleware. A good example is [ContainerMiddleware](https://github.com/mozilla-mobile/firefox-android/blob/5d773ae7710265ab98e15efc43b1afb17ab2e404/android-components/components/feature/containers/src/main/java/mozilla/components/feature/containers/ContainerMiddleware.kt)
357 Note that the `InitAction` here subscribes to a `Flow` from the database, which will continue to collect updates from the storage layer and dispatch actions to change the state accordingly.
360 Overall, this should convey the following improvements
361 - All state updates are guaranteed to be pure and in a single component, allowing for easy testing and reproduction.
362 - All side-effects are logically grouped into middlewares, allowing for testing strategies specific to the type of side-effect.
363 - Several indirect abstraction layers are removed, minimizing mental model of code.
367 ### An example refactor: removing interactors and controllers from HistoryFragment
369 A [small example refactor](https://github.com/mozilla-mobile/firefox-android/pull/2347) was done to demonstrate some of the concepts presented throughout the document by removing interactors and controllers from the `HistoryFragment` and package. This is not necessarily an idealized version of that area, but is instead intended to demonstrate the first steps in creating architectural consistency.
373 ## Drawbacks
375 Redux-like patterns have a bit of a learning curve, and some problems can be more difficult to solve than others. For example, handling side-effects like navigation or loading state from disk. The benefits of streamlining our architecture should outweigh this, especially once demonstrative examples of solving these problems are common in the codebase.
377 Additionally, the current implementation of our `Store` requires that dispatches are handled on a separate thread specific to the Store. This may change in the future, but also introduces some particular drawbacks:
378 - it can be harder to conceptualize the concurrency model, since there are no synchronous actions.
379 - thread switching has a performance impact.
380 - testing is more cumbersome by requiring additional async methods.
384 ## Proposal acceptance and future plans
386 To demonstrate some of the concepts discussed in this proposal in more detail, an example patch will be produced that shows a refactor of a prominent feature of the app - history.
388 Following that, should this proposal be accepted the following suggestions are made for adoption with a focus on increasing the team's familiarity and knowledge of the pattern:
390 1. Architecture documentation should be updated with guidelines matching this proposal.
391 1. A number (1-3) of refactors should be planned to help demonstrate the pattern, especially in regards to things like using middleware to handle side-effects, how to structure local vs global state, and how to propagate state and actions to Composables and XML views.
392 1. Where possible, new feature work should require refactoring to the established architecture before feature implementation.
393 1. Investigate further improvements to the architecture. For example, a "single Store" model that more closely follows Redux patterns.
395 ### Consolidating Stores
397 Multiple stores allows us to  manage memory with less investment. For example, state for a Settings UI screen can be held in a `SettingsStore` which can be cleared from memory (garbage collected) when no longer in that UI context.
399 This will mean that we will continue to have "global" stores like the `AppStore` and the `BrowserStore` co-existing with "local" stores like the `HistoryFragmentStore`. This can introduce complexity in terms of determining the best place to add state, middlewares, and actions.
401 Moving into the future, we will investigate options for consolidating our stores. It should be relatively straightforward to at least combine our global stores. Investigation is also ongoing into whether we can create "scoped" stores that combine a local store with the global store such that the local portion of the store can still automatically fall out of scope and respect the constraints of developing on mobile.