3 title: Remove Interactors and Controllers
4 permalink: /rfc/0009-remove-interactors-and-controllers
7 * Start date: 2022-03-29
8 * RFC PR: [1466](https://github.com/mozilla-mobile/firefox-android/pull/1466)
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.
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.
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.
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).
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:
65 // 1. in LibrarySiteItemView
67 val selected = holder.selectedItems
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)
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) {
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))
106 Following this proposal, the above would change to something like:
109 // 1. in LibrarySiteItemView
112 selected.isEmpty() -> store.dispatch(HistoryFragmentAction.AddItemForRemoval(item))
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
126 mode = HistoryFragmentState.Mode.Editing(state.mode.selectedItems + action.item)
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:
164 class PocketCategoriesViewHolder(
165 composeView: ComposeView,
166 viewLifecycleOwner: LifecycleOwner,
167 private val interactor: PocketStoriesInteractor,
168 ) : ComposeViewHolder(composeView, viewLifecycleOwner) {
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
178 categoryColors = categoryColors,
179 categories = categories ?: emptyList(),
180 categoriesSelections = categoriesSelections ?: emptyList(),
181 onCategoryClick = interactor::onCategoryClicked,
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:
190 class PocketCategoriesViewHolder(
191 composeView: ComposeView,
192 viewLifecycleOwner: LifecycleOwner,
193 ) : ComposeViewHolder(composeView, viewLifecycleOwner) {
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
203 categoryColors = categoryColors,
204 categories = categories ?: emptyList(),
205 categoriesSelections = categoriesSelections ?: emptyList(),
206 onCategoryClick = { name ->
207 components.appStore.dispatch(AppAction.TogglePocketStoriesCategory(name))
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.
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(),
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 }
249 oldestCategoryToDeselect?.let {
250 appStore.dispatch(AppAction.DeselectPocketStoriesCategory(it.name))
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(),
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
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:
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) {
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(),
299 Pocket.homeRecsCategoryClicked.record(
300 Pocket.HomeRecsCategoryClickedExtra(
301 categoryName = action.categoryName,
302 newState = "selected",
303 selectedTotal = currentCategoriesSelections.size.toString(),
311 // finally, we can shift all the state updating logic into the reducer
312 fun reduce(state: AppState, action: AppAction): AppState = when (action) {
314 is AppAction.TogglePocketStoriesCategory -> {
315 val currentCategoriesSelections = state.pocketStoriesCategoriesSelections.map { it.name }
316 val updatedCategoriesState = when {
317 currentCategoriesSelections.contains(action.categoryName) -> {
319 pocketStoriesCategoriesSelections = state.pocketStoriesCategoriesSelections.filterNot {
320 it.name == action.categoryName
324 currentCategoriesSelections.size == POCKET_CATEGORIES_SELECTED_AT_A_TIME_COUNT -> {
326 pocketStoriesCategoriesSelections = state.pocketStoriesCategoriesSelections
329 PocketRecommendedStoriesSelectedCategory(
330 name = action.categoryName,
337 pocketStoriesCategoriesSelections =
338 state.pocketStoriesCategoriesSelections +
339 PocketRecommendedStoriesSelectedCategory(action.categoryName)
343 // Selecting a category means the stories to be displayed needs to also be changed.
344 updatedCategoriesState.copy(
345 pocketStories = updatedCategoriesState.getFilteredStories(),
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.
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.