iOS Architecture Patterns for Large-Scale Development, part 1: Modular architecture

iOS Architecture Patterns for Large-Scale Development, part 1: Modular architecture

Aug 26, 2020 • 7 min read
Grid Dynamics

Introduction

Mobile applications help enterprises reach higher levels of success by increasing sales and providing improved experiences to their users. The share of sales generated via mobile applications continues to rapidly rise year over year, reflecting the increasing importance of mobile technologies in today’s digital world.

Each application development has several important challenges. This includes the reliability of the application, high development speed, code maintainability (flexibility), testability, and scalability, as well as the total development speed required to produce a quality product. Other considerations influence the success of the app development process but the ones listed above should be prioritized in any well-thought-out architecture of a project and application.

In this article, we’d like to share the experience of our iOS team working on an e-Commerce project, describing the ways we solved issues that we faced during development, as well as the pros and cons of the selected approaches. But before diving into the technical details of our changes, I’d like to touch on the situation we faced with the project before re-write and describe the reasons that made us think about refactoring.

The app consisted of about 3 million code lines before the rewrite. The codebase was mixed and written using Objective-C and Swift languages. The project contained 45 third-party and 6 in-house libraries stored in separate repositories and regularly changed. The team was large and consisted of more than 50 people with different levels of technical experience - from beginner to extremely senior.

All releases were carried out on time. However, despite the size of the team progressively growing in size we noticed that the volume of deliverables remained unchanged. Technical debt accumulated and led to other problems such as increases in crash rate, difficulties in understanding code, and delayed delivery of features that required important architectural changes. All these changes were ultimately accompanied by global solutions such as fragmentation of the monolithic codebase in favour of modularity, migration from a Multi-repo approach to Mono-repo, and revision of the dependency management process.

In this article, we will show you how we were able to achieve a completely rewritten architecture for the application. It allowed us to overcome the issues and limitations the team was increasingly facing on the project. We will also describe how the changes led to the creation of a well-organized development process, increased team productivity, and improved overall app stability.

So, let's look at the overall state of affairs with the app development from a high level right down to low level perspective as well as detailing both global and more granular issues and the ways we solved them.

Modularity

It is a rare case when a team thinks about the modularity of a developed application at the start of a project. Greater focus is almost always placed on starting development and seeing the final result of the work when delivered to the customer rather than focusing on architectural solutions. But this can be a mistake.

Firstly, it’s worth pointing out the reasons why you should think about the modularity of your  application. During development the main application module usually becomes huge. It becomes difficult to scale, test, maintain, and as a result, to deliver. Team growth also affects architectural decisions, namely the approaches and tools that are used must meet the needs of a large and dynamic team.

Modularity also encourages a sustainable system of knowledge-transfer as modules are usually written by different people and those teams rarely intersect so experience interchange occurs while discussing, implementing, and sharing information about the different modules. Problems start arising not only on the side of the codebase development but also in Continuous Integration processes.

The following are the main problems that the team encountered and, in fact, were the reasons that modularity was introduced in our projects:

  • Huge code coupling;
  • Codebase mess, which leads to confusion of the feature’s borders in the code;
  • Build time when one change forces the whole codebase to be recompiled;
  • All the tests run even for a small isolated changeset;
  • Implicit dependencies of each feature and component are not obvious.

All of these points became the starting point for thinking through the modular architecture of the application and the project as a whole. Since the entire codebase was in the one module and all the functionality was coupled, the first step was to start disassembling the monolithic wall of bricks into smaller independent modules.

The obvious solution was to define first the core functionality to extract it separately. Such modules are used by the top layer of the application and they may also have a dependency on each other, but this is a rare case. These modules are not tied to a specific application and can be reused anywhere. Therefore, this level is called App Independent.

However, this turned out to be only a small part of the code, which we managed to separate from the monolith. About 85 percent of the codebase is accounted for in the implementation of features and their business logic. Thus, the next step was to separate features from each other and put them into separate modules, as was done in the App Independent layer.

This step is the most painful. Firstly, we saw how each module seemed to be dependent on each other and tightly coupled, pointing to the flaws and weaknesses of the architecture. So the main rule that developers should follow while working on these modules is no horizontal dependency at the Features level. This means that one feature module should not depend on another one.

Secondly, we had a dilemma of what to do with reusable components which, on the one hand, belong to the Features layer, but on the other hand don’t belong to any specific module. Thus, another layer, App Specific, was established.

An important condition is that the modules of the App Specific level cannot be horizontally dependent on each other, as in the case with the Features level. Only modules from the lower level, App Independent, can be used.

As mentioned above, no feature should depend on another feature. The question then is, how is navigation between features in the application carried out? A module from which level is responsible for the initialization and configuration of the application?

Host App is the top level of the architecture. This level determines the state of the application and configuration type and it receives notifications from the OS.

The architecture of the entire application can be represented schematically as follows:

Thus, Host App is the only layer that is responsible for navigation between features, features configuration, and communication between them. To ensure low modules coupling we must follow the Dependency Inversion principle to increase in the order their maintainability and testability. Such dependent modules as Repositories, Services, and Analytics should be injected into the feature modules through a protocol with necessary parameters and settings at the Host App level.

To implement transitions between features, a Router object was introduced that knows how to navigate from one feature to another, taking into account different conditions and states of the application. The Router is part of the Host App layer, which conforms to a Routing protocol for a specific feature to increase in the order be injected into the appropriate module and to provide transition logic. Here is a representation of the assembly of the Product List module and the navigation from this feature to the Product Details feature using the Router object:

Mono-repo

After the monolithic application was divided into parts, the modular architecture was built, and the rules of the game were established, the next step was to determine how to manage all these dependencies. It’s important to note that the number of modules has already exceeded 50, and the application continued growing and new functionalities were added.

The crew was divided into two factions. The first group voted for the Multi-repo approach that we had used previously, while another group insisted that Mono-repo would be a better solution. Of course, each approach had its pros and cons, but given the circumstances we had on the project such as a large team, lots of modules, and rapid releases, the Multi-repo approach would likely face greater problems. This was because developers would probably end up spending most of their time not on code development, but its integration. Here are the main disadvantages of Multi-repo:

  • A large number of repositories we would have to manage;
  • Many MRs we have to prepare and review;
  • Need for a simultaneous merge of several MRs in a short period to support breaking changes;
  • Frequent merge conflicts and their resolution;
  • Caring for versioning of internal Pods;
  • The code is not consistent.

The case in favor of Mono-repo was strong and is supported by the experience of the second group. The entire codebase of modules and associated projects where these modules are developed and maintained are stored in a single repository. The feature is integrated into the main application module when it is completed. Therefore, many of the issues outlined above can be avoided by using the Mono-repo approach:

  • The whole codebase is in one place;
  • Atomic commits;
  • Code consistency is always guaranteed;
  • Each feature has its playground project;
  • Simplified dependency management;
  • Eliminates merge conflicts;
  • No need to care for versioning of internal Pods;
  • Provides good project scalability;
  • Global refactoring is easier;
  • Team productivity is better;
  • Mono-repo encourages keeping a project modular.


All these arguments pushed us towards favoring the Mono-repo approach, which was eventually approved by the leadership of the project. But, like any other approaches,  there are also some disadvantages to using Mono-repo that are worth mentioning:

  • A repo might be heavier;
  • It obliges the file system to have a certain structure;
  • Lack of per-project security;
  • The CI/CD process is more complicated.

We split our monolithic application into separate parts ensuring the modularity of the system. Modularity and Mono-repo, as well as a multi-layered architectural solution, helped us to solve issues and provide flexibility for future changes.

The next part of the article describes exactly how our team went about integrating the rewritten modules into the main application and the dependency management approaches we considered.

Subscribe to our latest Insights

Subscribe to our latest Insights