Monorepos and why to consider them for your team’s iOS projects
Modern iOS projects are complicated. Not only can they contain apps, but over the past several years Apple has added many different kinds of app extensions and even new platforms (tvOS, watchOS), all of which can run the same code. And beyond that, projects like Vapor let you run Swift on the server, which makes it possible to not only write entire end-to-end applications in Swift, but to share code between services and the apps that talk to them. Consolidating and sharing code across interrelated projects is a powerful concept, and it can greatly increase development velocity on the team. But it inevitably raises the question: how should you approach the storage of different projects and libraries in source control, to enable code to be reused efficiently between them?
In this post we’ll focus on one popular strategy for consolidating shared and related code in modern iOS apps: storing everything in a single repository under source control — also called a monorepo.
This page provides a good primer on monorepos from the perspective of a web developer. It doesn't address the specific mobile use case, but that's what we're here for 😀
The problem with separate repos for iOS apps & Swift packages
By far the most common approach to organizing the storage of an app’s code is maintaining a separate repo for its Xcode project and for each (internally developed) Swift package it depends on. This is the default when creating a new Xcode project — in fact, the New Project dialog offers to create a git repo for you — and Swift packages are assumed to be at the root of a repository when bringing them in via Swift Package Manager. This is the path of least resistance in terms of going with what the tooling provides for out of the box, and it is often a good enough starting point.
But as your app evolves and grows to include more extensions, packages, and even other apps, sticking with this default approach is not a panacea. There can be significant downsides to managing multiple repositories – for example, having to configure CI/CD, pull request checks, and automations in each place they are needed across all repos, which duplicates efforts and makes critical tooling difficult to manage and keep consistent. Propagating changes can also be cumbersome. In order to make a change to a Swift package that’s then consumed by an app, say, you’d first need to create a PR in the package’s repository, create a new version of that package (following proper semantic versioning of course) and then pull that new version of the package into the app, using your dependency manager of choice or git submodules. Which of course calls for yet another PR of its own. If your team is making changes to packages regularly, this process can quickly become tedious and a drain on productivity.
Enter the (iOS) monorepo: less overhead, with all your code in one place
What if you could avoid that wasted time and extra overhead by keeping all the code required for your app in one place? You can, and that single repository has a name: the monorepo. Keeping all the code for your apps, extensions, and packages in one spot has its advantages. For one, there’s less overhead involved in contributing to multiple areas of the codebase – if a change needs to be made to a supporting package, one PR can cover it; there’s no need to jump across repositories and deal with versioning changes to a package. And, CI/CD tooling only needs to be spun up for one repository, which limits the surface area for mistakes or inconsistencies in complicated workflows, making them simpler to maintain.
Many teams find the prospect of consolidating their disparate codebases into a monorepo daunting, but it’s possible to transition smoothly by following some best practices. It's vital that your team establishes good conventions to follow — things like standardizing the directory structure and having consistent naming patterns can go a long way towards improving how one navigates and contributes code to a monorepo. Additionally, investing in making infrastructure config and related code (like CI/CD YAML or fastlane lanes) more generic can streamline your setup and make it easier to spin up tasks that are common to a number of your projects — like running an app's tests and making builds. Finally, however streamlined you can get things, remember that consolidating projects under a monorepo umbrella can be a long process – and that’s okay! Approach the journey incrementally, one project or package at a time.
Tips for getting started with a monorepo
Set up an intuitive and performant directory structure
Choosing an intuitive directory structure is an important first step in setting your team up for success using a monorepo. With a reasonable directory structure in place, you can lean on conventions (for example, where app projects are located relative to supporting packages), and use those conventions to create CI/CD workflows and other scripts and tooling that can accommodate any of your projects.
Take a look at the following repo directory structure:
A layout like this is a good starting point, and it can easily be adapted to your needs. Notice the separation of app projects from their supporting libraries (which are the products of Swift packages). Building and structuring apps this way encourages separation of concerns and allows the Xcode & Swift Package Manager build systems to function most efficiently.
Swift packages can be added locally to either an Xcode project or to another Swift package. You get all the benefits of working in the package context, while still working on your packages side by side with other packages and app code. Defining a local package dependency in a Package.swift file is relatively simple:
This manifest sets up the <code>UIToolkit<code> package which depends on a sibling <code>Utilities<code> package. You can also add a local package dependency right from your Xcode project by clicking the "Add Local..." button in the Package browser dialog.
Organizing related pieces of code into modules that are exported as packages (and kept under the Packages directory) has another benefit — it can dramatically improve the state of your dependency graph. For many years Xcode has allowed a file to belong to multiple targets, which can quickly get confusing and cause dependency hell: if you add a file to a different target and it depends on other files not in that same new target, then there’s work to do to bring everything in. Keeping related files in a single library, which is then imported by one or more packages or apps, helps avoid this situation, all while encouraging thoughtful consideration of associated public APIs, leveraging Swift member visibility, and even improving build times.
Optimize automated workflows
As you build out your monorepo and it continues to grow, you may notice that automated checks on pull requests will start to take longer. Most tools will run from the root directory out of the box, so even a small change to one file will, by default, run checks on the entire repository — and for monorepos, this can quickly drive up CI build times and associated costs. Luckily, tools like [Danger](https://danger.systems), which encapsulate code convention tools like Swiftlint and SwiftFormat, can help optimize automated workflows by only running checks on the changed files in a PR.
A similar strategy can be used for running tests: ideally, a project’s test suite only runs if there have been changes to files in that specific project. To accomplish this, one straightforward approach entails a simple helper function, used across test runner scripts, that detects if anything in the project’s tree changed, and exits early if not. The following git snippet can take in a path inside of your repo and determine if files were changed in that path:
Using this snippet, we can first check if there have been files changed in the MyApp directory, and only run tests for MyApp project if files have changed. Given some conventions in repo structure and naming, this is a powerful way to run tests in a consistent manner across your monorepo.
Additional updates to your CI/CD workflows to accommodate monorepo directory structures are often needed. For example, you may need to update your Swift package deployment mechanism to look for nested Swift packages, since it's not a typical file structure. This is doable though (see this example of a Heroku buildpack which does just this thing). You’ll also likely need to make some updates to any workflows that distribute builds, to ensure you’re not over-deploying from unrelated changes. The git snippet we looked at above can also help deploy scripts detect if there were changes to specific paths in a given commit, so that only relevant CD workflows are run on changes to app, package, and server paths.
Slow and steady — fold projects into your monorepo one at a time
With a robust repo structure in place, and tooling updated and optimized to run on monorepo projects and packages, you can easily continue to fold in more projects to your monorepo if it makes sense to do so. Bringing projects over one at a time if you have multiple to migrate is a good rule of thumb, and it’s even possible to preserve git history during this process. Always ensure that the app builds, tests pass, and that any existing CI/CD workflows are running as expected before moving on to the next project. It's worth taking the time to make sure this is done right.
Words of warning: monorepo downsides
Monorepos can have many advantages, but there are certain scenarios in which opting for a monorepo may require extra consideration. For instance, including packages that need to be shared across the entire org — including with projects that aren’t part of the monorepo — might be more trouble than it’s worth. Consumers of such packages on other teams or projects would need to bring in the entire monorepo as a submodule (don't do that) or else there would need to be a process for publishing just those needed packages alone, which adds overhead and can be difficult to set up and manage correctly.
Distributing packages for external consumption (even if just within your own org) necessitates a bit of a cultural shift, a change in a team’s development mindset. While similar conceptually, app distribution and framework distribution follow slightly different processes, and needing to work with both can have a ripple effect on a team’s overall practices. Care must be taken to observe proper SemVer principles when publishing changes to a package to properly notify consumers of breaking API changes, for example. For these reasons, if a package is shared more widely across multiple teams within an org, the more sustainable approach might be to break it out to its own repo and import it as a dependency where needed (e.g. using Swift Package Manager). While this does break up the monorepo and create a mix of mono and many repos, it’s likely the simplest solution for enabling cross-team code sharing without adding a lot of deployment overhead.
Monorepos from iOS to Android and beyond
Although our focus in this piece has been on iOS and setting up a monorepo to pull together iOS app projects and Swift packages, many teams choose to leverage monorepos across many different types of projects — even spanning different languages and platforms. There's no reason an Android project couldn't live in our monorepo’s Apps directory, and the Packages directory could include things like Vapor applications deployed on the web. Supporting multiple platforms naturally requires more effort to properly set up tooling (build scripts, PR checks, and the like) but it's absolutely possible and the benefits of a wider org working within a single repo could make it worthwhile. Even huge organizations like Google have been known to host their entire code ecosystem within a single massive monorepo!
Consolidating apps and packages into a monorepo can be a great way to reduce tooling maintenance and to standardize and simplify the development process across related — or even not-so-related — areas of code. While setting up a monorepo can initially seem daunting, following a few best practices like setting up a clear and intuitive directory structure, generalizing and optimizing workflows, and migrating projects one at a time can make the process of moving to a monorepo a lot easier. Before long, your team will be hopping across apps, packages, and utilities, making changes seamlessly across the board, all in a single repo.