Scaling up JustEat Help Centre on iOS

The process of restructuring legacy code

Overview

Ageing affects all living creatures, as well as code and software.

As time goes by, a business may change its objectives, and sometimes existing code needs to be retired or restructured.

The process of writing code is often done in a rush, under the pressure of strict delivery deadlines. This leads to a lack of documentation, tests, and the use of well-known design patterns, making the code hard to maintain after a while. There is also ageing introduced by deprecation and the release of new Software Development Kits from Apple and third-party dependencies. The result of all of these factors leads to a code base that is known as Legacy, which can be hard to change and maintain.

In this article, I’ll explain how we have dealt with updating legacy code using the example of the Just Eat Help module on our app.

Even though the module is different to the other modules within our iOS app, the process of restructuring it could be useful to understand and be helpful in other cases.

Just Eat Modular iOS Architecture consists of one main App and multiple modules, they could be Domain-specific, Core and Shared.

The Help domain module consists of a Framework and a Demo App, which allow us to run UI Tests and Unit Tests on the module framework.

Rewrite or Refactor?

The first sign the code is hard to maintain is that the team tends to avoid changes, even if they are small. Unpredictable events and production incidents are happening just because of a small change. At some point, everyone starts thinking that something must be done to speed up the development process and to keep all the platforms aligned. Should the module be rewritten or refactored?

Before answering this question, we performed a code review to inform our decision.

Code Review

The code review process combines senior iOS developer expertise with code analysis tools.

We evaluated the module using the team’s best practices. We used the following tools to have a more objective outcome:

  • SonarQube: Code Coverage, Code Smell, Size, Cyclomatic complexity and Cognitive complexity.
Code Coverage and Cyclomatic Complexity

- SitRep: Gather information about code size and the amount of UIView, UIViewControllers and SwiftUI views.

OverviewFiles scanned: 252
Structs: 87
Classes: 145
Enums: 35
Protocols: 79
Extensions: 187
SizesTotal lines of code: 19602
Source lines of code: 17989
Longest file: HelpFlowCoordinator.swift (958 source lines)
Longest type: HelpFlowCoordinator (879 source lines)
StructureImports: Foundation (207), JustUI (101), UIKit (58), JustTrack (15), JustAnalytics (14), ScrollingStackViewController (14), JustLog (4), DateFormatting (3), MessageUI (3), ErrorUtilities (2), SwiftUI (2), ChatSDK (2), MessagingSDK (1), ChatProvidersSDK (1), WebKit (1)UIKit View Controllers: 32
UIKit Views: 14
SwiftUI Views: 1

- Emerge: Gather insights about source code structure, metrics, dependencies and complexity of software projects.

  • Jira: Open Issues and history.

Find out the pain points

During the code review, we found the following issues:

  • Legacy disabled features have been left over from the codebase.
  • Mixed use of Storyboards and programmatic UIViewControllers.
  • Nested UIViewControllers hierarchies.
  • Huge classes and massive view controllers.
  • Navigation issues.
  • Unclear code boundaries between the module and the App.
  • The module’s internal architecture was difficult to understand.
  • Reuse of UIViews in different use cases (SRP violation).
  • The module implemented a backend-driven UI, but lacked use case documentation and tests.
  • Bugs and issues were difficult to reproduce without deep domain knowledge.
  • UI Tests could not represent the production workflows as the backend tests could be outdated.
  • High cyclomatic complexity.
  • Lack of code coverage for big classes.
  • Lack of feature documentation.

We grouped the findings into 4 Pain Points:

  • The code is difficult to understand.
  • Development is slowed down.
  • Issues are detected too late.
  • The code is difficult to test.

In one sentence: it doesn’t scale up!

Identifying the desired results

We identified the outcome we want to achieve:

  • Speeding up development.
  • Improving reliability.
  • Improving maintainability.
  • Improving readability.
  • Improving testability.
  • Aligning the Module to iOS Core Team practices.
  • Improving documentation.
  • Using a well-known Architecture.
  • Improving Demo App.
  • Expanding the Demo App with features show off.

Restructuring the code

The temptation of rewriting from scratch seemed alluring, but we avoided following that route for two simple reasons:

  • We lacked the documentation of each feature.
  • We wanted to avoid a high-risk big bang release.

We decided to have a mixed approach between refactoring and rewriting, which we call restructuring. The idea is to tackle a specific issue at a time, choosing between a bounded rewrite or a refactoring. This iterative approach reduces the risks during the releases and avoids a big-bang release. It also helps to gather knowledge about the module and its features.

As a team, we agreed this would be the best approach and the right amount of time to complete the activity.

We identified the following iterations:

  1. Improve Documentation and Folder Structure.
  2. Remove leftover code.
  3. Remove all Storyboards and implement DarkMode.
  4. Refactor View Controllers using MVVM.
  5. Reduce Module code in the main App.
  6. Compliance with iOS Core best practices.
  7. Refactor Navigation with Flow Coordinators.
  8. Architecture compliance.
  9. UI Tests improvements.

1. Improve Documentation and Folder Structure

We gathered all the information about the module features and reorganised the code moving the code under a folder structure representing a feature:

/Features/ContactCenter
/Features/FAQArticles
/Features/HelpCenter
/Features/SelfService

We also added a Markdown document for each feature, containing the related knowledge.

Having good documentation committed in the source version control helps other colleagues understand the module code and features.

The documentation activity is iterative, and we expect to maintain it during the development process.

2. Remove leftover code

The more code you write, the more you have to maintain.

The documentation process revealed that part of the code was not used by any feature.

The first step before tackling other activities was to remove all the leftovers.

  • We removed disabled features.
  • Unreachable code.
  • Consolidated features by removing Feature Flags.

During this phase, we removed around 23% of the code of the entire module.

3. Remove all Storyboards and implement Dark Mode

The majority of the UIViewControllers in the module were implemented using Storyboards and XIBs.

The disadvantages of using Storyboards and XIBs are well known *

We found the following issues relevant to us:

  • They fail at run-time, and extra effort is required on tests to prevent it.
  • They are hard to read when there are multiple segues and codes to prepareForSegue.
  • The code review is harder.
  • Higher rate of merge conflicts due to the XML format of Storyboards and XIBs.
  • Hard to refactor and reuse.

During this phase, we converted all UIViewControllers to code without changing configurations and removing all the storyboards and the XIBs.

The critical part of the task was to ensure that:

  • UIViewControllers’s properties were mapped to code.
  • Auto-Layout’s constraints were mapped to the code.
  • There is no regression in the App and the Module.
  • The UIViewControllers are instantiating as before.
  • There is no regression in navigation.

In addition, we completed the implementation of the Dark Mode, which is easier to control via code.

override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {super.traitCollectionDidChange(previousTraitCollection)   // Configure the UI with the right style
configureUIStyle()
}

4. Refactor View Controllers using MVVM

The module was written with UIKit, but we wanted to use SwiftUI eventually. The easiest way to obtain this goal is to make sure all the UIViews are implementing MVVM using Combine.

MVVM:

View: Implements the presentation of ViewModels, and binds user interactions with the ViewModel.ViewModel: Implements the business logic to interact with Services and binds Views to Models and data.Model: Mutable data entity used by ViewModels and Views.

During this step, we resolved the following issues:

  • The UIViewController were nested and created a complex hierarchy.
  • There was no clear boundary between business logic and UI.

We proceeded in the following way:

  • Decouple UIViews from UIViewControllers.
  • Decouple business logic from each UIViewController and move it to new ViewModels.
  • Use Combine to bind ViewModel to UIViews.

The outcome of this step was an improvement of the UIViewControllers code coverage to more than 85%.

5. Reduce Module code in the main App

The presence of many public methods in the Module left the door open to add features to the main App.

The side effect of this way of using the module consisted of a demo app with different behaviour from the main App.

To solve the issue we implemented a protocol to start the Module and hide the internal module features from the App.

We reviewed the way the main App interacts with the module:

  • Implemented a clear boundary between the App by adding protocol for the Module entry functions.
  • Improved readability using swift async/await.
  • Removed a third-party dependency for Future and Promise.
  • Reduced the number of public functions exposed by the Module.

The outcome was to reduce the code in the main App by 56% and the alignment between the main App and DemoApp.

In addition, we also had a reduction in size of the bigger classes contained in the module.

6. Compliance with iOS Core best practices

We aligned the module to the latest team practices:

  • Xcode project generation using Tuist
  • Use Swift Package Manager in favour of CocoaPods for dependencies
  • Implement Unit Tests in the Framework
  • Implement UI Tests in the DemoApp
  • Enable UI Tests parallelisation
  • Same SwiftLint configuration for the main App and DemoApp

We achieved a more stable CI pipeline and improved early error detection.

7. Refactor Navigation with Flow Coordinators

The nature of the Help module is dynamic as it’s required to change UI and navigation without waiting for a new release. The same UIViewController can be presented dynamically from different UIViewControllers through dynamic action buttons.

To simplify the navigation complexity introduced by the backend-driven UI, we introduced a pattern called Navigation Delegation with Flow Coordinators.

What does a Flow Coordinators do?

  • Decouples navigation from ViewControllers
  • Is responsible for the navigation logic
  • Is responsible for coordinating ViewControllers
  • Can have children Flow Coordinators

The Module was implemented with Flow Coordinators, but a dedicated delegate for each Flow Coordinator has led to a massive parent flow coordinators and unreadable code.

To improve readability and maintainability we introduced the `Chain of Responsibility` pattern during the navigation between Flow Coordinators. Navigation delegation means that a Flow Coordinator could delegate their parent or one of their children to perform the navigation through a common protocol.

public protocol FlowCoordinator {
func navigate(route: Route, from: UIViewController?)
var flowCoordinatorDelegate: FlowCoordinatorDelegate? { get set }
}
public protocol FlowCoordinatorDelegate: AnyObject {
func flowCoordinatorWillNavigate(_ flowCoordinator: FlowCoordinator, route: Route, from: UIViewController?)
func flowCoordinatorDidNavigate(_ flowCoordinator: FlowCoordinator, route: Route, from: UIViewController?)}public enum Route {
case call(phoneNumber: String)
case callRestaurant
case callSupport(phoneNumber: String, customerCareWorkingHours: String)
case email(config: EmailConfiguration)
case link(link: HelpCenterLink)
case linkWithURL(string: String)
case liveChat(info: LiveChatInfo?)
case login
case orderDetails(config: ContainerInOrderDetailsConfig)
case offlineRequestForm(config: OfflineSupportRequestConfig)
case refresh(orderId: String)
case refreshAll
case search(sections: [Section])
case showError(error: Error, screen: String?)
case showSection(section: Section)
case showArticleWithId(articleId: String)
case showArticle(article: ArticleProtocol)
case workflow(action: Action, items: [Item])
}

The following chart explains the hierarchical structure with all the different routes.

Usage of the Navigation delegation pattern with Flow Coordinators improved the Unit Tests related to navigation. With this pattern, we achieved an overall module code coverage of more than 81%.

8. Architecture compliance

Refactoring is never perfect, so we spent some time reviewing the module and ensuring all the parts of it were conforming to the architecture of our choice: MVVM + Flow Coordinator and Navigation delegation.

During this step, we completed the conformance.

9. UI Tests improvements

Another issue introduced by the dynamic nature of a backend-driven UI was the lack of documentation of existing use cases on the iOS App.

In the end, the best documentation of the code consists of the amount of UI and Unit Tests written and maintained. Another good way is to showcase the feature of a Module through its DemoApp.

UI Tests were made during the feature development by using the JSON response collected from the Rest API but never updated to reflect backend changes and feature improvements.

The DemoApp was very hard to showcase all the different use cases due to the lack of knowledge of the configuration required to present them.

We reused a mock server containing all the use cases we used for backend testing, and we added a UI in the demo app to browse all the use cases.

To keep the UI Tests synchronised with the Rest API, we implemented a Record/Replay feature in the demo app, allowing us to store mocked responses during the recording phase and reuse them during UI Tests.

We plan to use a shared configuration to drive UI Tests for all the frontend platforms.

A backend-driven UI requires a backend-driven test suite.

Conclusions

A backend-driven UI and Navigation create many code challenges on the iOS client side. Most of the changes using this architecture are happening on the Rest API side, but the mobile client needs to be kept in sync, updated, and tested regularly.

Leaving clients behind increases the risk of future release and could slow down the overall development in the end.

In this article, we presented our solution to reduce technical debt and legacy code and to keep up with the challenges imposed by a backend-driven UI.

Just Eat Takeaway.com is hiring! Want to come work with us? Apply today.

--

--

Articles made by the techiest people at Just Eat Takeaway.com

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store