Growth in code bases come with exciting scalability challenges. As the size of our iOS codebase and team at SoundCloud grew, we faced challenges: long compile times and conflicts. Our productivity started to suffer as a result. We took inspiration from the work done in the backend (Building Products at SoundCloud) and applied it to mobile development. The main goal was to get back to a state where development is fun, fast, and would scale as the number of contributors grew. We modularized our iOS project by splitting it up into modules with well-defined responsibilities and public interfaces that interconnect them.
It’s been a little over a year since we started our journey towards a modularized iOS app and we’re very happy with the results that we’ve achieved so far. In this series of posts we’ll talk about the modularization journey, the motivation behind deciding for modularizing the app, a technical overview of the approach, and the impact it has on developers when they work on new features.
Originally, the iOS app was organized in two targets: one target that compiled the main app, and another target that compiled and ran the unit tests. The source code was mainly Objective-C, although some developers had already started adding Swift to the codebase. Moreover, the project used CocoaPods to fetch and statically link around 20 dependencies. The product was growing at a good pace and so did the size of the two main targets. The build time became very high, and the lack of some control over how things were getting imported triggered clean builds when a single file was modified. Waiting for the build gave us the perfect amount of time to grab a coffee, but we’d have a serious problem with our caffeine intake if we didn’t do anything about it. In a team of 10 people, doing 20 clean builds per day, each taking 7 minutes, the cost was 1400 minutes, 23.3 hours, or 3 days of work. These build were a waste of time and money. Also, delivering features took too long compared with our other platforms.
On top of all that, CocoaPods and our git branch-based workflow made the problem worse as we had to run
pod install on most branch switches (It’s worth noting that CocoaPods has improved a lot since. The latest version prevents Xcode from invalidating its build cache after running pod install.).
Most of our development time went into the compilation of the huge app and tests target. We undertook research by looking at peer companies and conducting interviews. After careful research we put all the options on the table and decided towards splitting the iOS app into modules. With smaller compilation targets, modules would take much less time to compute and run, and developers could quickly iterate over better solutions. Moreover, smaller targets would encourage good API design, and allow us to leverage tools like Playgrounds and example apps to iterate on features without compiling the main app.
Our research showed that most companies going through this transition had bigger teams than us, including teams dedicated to building and maintaining tools to facilitate their transition. We had to come up with a plan to gradually transition to building modular features, iterating over the new architecture while still allowing the team to build features.
Some companies are very successful using React Native, including SoundCloud with Pulse. We ruled out that option because the framework comes with integration costs that we could not assume in the listener application: tooling maintenance and training for instance. However, React Native makes a lot of sense for some features and we may reconsider it in the future.
Clean interfaces (APIs)
When all the code is in the same target, having access to any other class is a matter of importing a header (Objective-C), or having an internal access level (the default one in Swift). It’s flexible, but at the same time dangerous since it allows everyone to create dependencies with almost no control. Having no control over how dependencies get imported leads to a very messy dependency graph, coupled code, and eventually to unpredictable states propagating through that graph. In a small app, with a small team, you can avoid these issues with code review. However, with our application and our team this was too hard to do.
Modules help you prevent these issues by exposing an API that you as a developer need to define. As part of your workflow, you need to define how the API of your module will look like and how consumers of your API will interact with it. In Objective-C that means specifying which headers will be public, and in Swift, which components will have the public access level. An API defines a contract in the same way REST APIs do, and developers who consume your API will have to comply with it.
Since modules include standalone features they can include example app for testing the features without the main iOS application. We recently built the new home screen in a separate app using test data, which allowed us to test different scenarios:
- Having tracks with long titles that exceed the limit of the label.
- Having tracks with no artwork.
- Having no data because of a connection error.
With example apps, you can iterate on your feature with fast build and test times, designing the public API that the main application will eventually use. Following with the example above, the home feature would expose a view controller that the main application would push on the view hierarchy.
The image below shows the structure of one of our modules that include an example app that you can experiment with.
Similarly to an example app, Playgrounds allow you to import your module and use its APIs. Changes run in real time and you can even see UI components there. We found Playgrounds very useful for documenting and onboarding.
You can check out this episode that shows how Kickstarter leverages Playgrounds to develop their features and try them with different scenarios.
Fewer merge conflicts
Having a single project that indexes all the files and configurations in your projects leads to conflicts quite often when more than one branch is modifying the project file. By having different projects that contain the modules we still encounter conflicts but they are less probable. As a result, we spend less time solving conflicts on git when someone merges first and also modifies the project file.
Exploring new patterns
Working on modules allows developers to decide about the code architecture and the patterns that they want to use internally. They can even choose the language, for instance, using Objective-C instead of Swift, or maybe even React Native in the future. However, the public APIs of these modules need to comply with a contract to ensure there’s consistency across all our public APIs. We’ll talk in more detail in the upcoming articles about what the contract looks like.
Moreover, we’ve tried to commit to Cocoa patterns for the interfaces of core components that modules have access to. Since developers are familiar with those patterns (delegate, completion block…) they don’t need to spend time figuring out how to hook those components into their features.
Maintaining a modular setup becomes a cumbersome process when you have a lot of modules.
.xcconfig files are a good tool to ensure that all targets share the same configuration, yet it’s not enough. When it comes to specifying the targets that you want for each project, the schemes that are available, and the configuration of those schemes, there’s no tool or API that you can leverage to automate the process of creation and maintenance. We could use a tool like CocoaPods but they would decide the structure of our modules, which is too restrictive for our requirements. Buck is another option that we considered, but it didn’t fully support Swift back then and would have required a lot of investment that we couldn’t afford.
We are internally exploring some tools to automate the process of creating and maintaining new modules.
In our experience, having a large amount of external dependencies brought unnecessary complexity and work, so we tried to reduce them. However, there are some dependencies that are necessary, which we need to fetch and link from the app. Some examples of these dependencies are Crashlytics, Firebase or Facebook. Each dependency is distributed in a different way. Some provide a .podspec that you can use from CocoaPods. Some others publish the compiled framework/library and headers that we can fetch and link from the app. If they support Carthage, they include a project that gets compiled by Carthage and whose output
.frameworks gets copied to the Carthage build folder. There’s no standardized way to distribute dependencies and that makes things really hard, especially with no dependency management tool.
There’s some manual work that we had to do in this area to generate a compiled version of the dependency that we can use, and that includes information about the version. Unfortunately, there’s no automation that we could do since as I mentioned, there’s no homogeneity in the setup of the dependencies.
In the next post of this series, we’ll dive into technical details. We’ll present the current project architecture, guiding you through the principles of it, as well as some iterations the architecture has gone through based on the feedback from the team. Stay tuned!