Scaling Development of an Android App

jameson

Jameson

Posted on September 14, 2021

Scaling Development of an Android App

This article explores different strategies for organizing and packaging Android source code as your organization grows. We also look at ways to divide up ownership across different developers in your organization. We’ll follow a typical growth trajectory of an Android app, starting from a simple toy project and ending up with a large consumer-facing product.

Act 1: The Simple Sample App

When you’re working on a small app by yourself, your project structure likely looks something like a Matryoshka (“Russian-nesting”) doll. At the outermost layer, you’ve got a single Git repo. The repo contains a single root Android Gradle project. In the Gradle project, you’ve got a single Android application module called "app.” Inside the app module, you might have a few small Kotlin packages into which you’ve separated your presentation logic from your domain modeling code. Lastly, each Kotlin package contains a few files/classes.

Structure of a basic Android project

Right now, you're the only person working on this codebase, and you have access to everything. When it comes time to prepare an apk for installation, there are no intermediate steps. All of the files are compiled and indexed in one big bang.

Act 2: The Prototype That “Made It”

Good news - the business has decided to move forward with additional investment in your little Android app. As you build out more features, you’re going to add more Kotlin files and more Kotlin packages. Separating your code into small files and packages will encourage clean single-responsibility components, and self-document the relationships between them. At this phase, your project might have a hierarchy of Kotlin packages and dozens of Kotlin files/classes.

Structure of an early-stage app that is growing

Act 3: A Real Production App

After a while of building features for the business, you’ve got a "real" Android app on your hands. A few key changes occur at this phase.

Firstly, you might have additional developers working with you on the same codebase, now. And for now, that isn’t too much of a problem. There are only 2-5 of you, and you’re staying in close communication about design and implementation. You review each other’s code and you rely on “individuals and interactions over process.”

With the new investment, you’re getting lots done -- and your single codebase might be starting to get unwieldy. Perhaps the app module’s build.gradle is getting longer and more difficult to keep organized. You’re probably also thinking about which of your components can be re-used and which are the best ways to do it. Maybe some of those utilities are even generic enough that they could be useful beyond your immediate project. You’ve got some decisions to make.

Firstly, should you split the utility code out into a new library module? Generally speaking, this is a good idea if you’ve got a well-designed public contract that’s being exercised by 3 or more other components. It’s also a pretty minor change to make, and with little downside. The primary gain here will be to simplify your build script and to create a more explicit barrier between groups of interacting components.

Structure of an Android app with several Gradle modules

Creating re-usable library modules will also help reduce your build times, since Gradle will be able to recycle any already-built modules that haven’t been touched. There are a number of steps needed to produce an apk, but re-usable library modules can (at minimum) chip away at the incremental compilation work that’s needed to construct the final apk.

With a bonafide library module in your project, you’ve unlocked an additional capability. You can now release your library module as a public Android ARchive. Android libraries such as OkHttp or Glide are released this way, and are distributed via the Maven Central Repository (or Google’s mirror of it.) And just as is the case with OkHttp or Glide, your app can depend on a remote copy of your prebuilt library instead of directly on the local module code.

Interaction between application and library modules with an external artifact repository

There are a few benefits to doing this. Even with a local library project, you have to do at least one initial build to create the local library artifact. Not so with an external library. Now, you just pull a built artifact and skip that entire compilation unit.

By the time you’re publishing a library artifact and your app depends on it as an external artifact, there’s little benefit to keeping it in the same Gradle project. So, you can further simplify things by splitting the repo into two Gradle projects entirely.

Structure of an Android repo with two Gradle projects

This allows you to operate on a smaller application project, skipping some compilation, and with some code out-of-sight and out-of-mind.

Act 4: An Enterprise Monorepo

Few organizations ever grow beyond “Act 3.” But there are several additional challenges that creep up, for those that do. Your team might have some of the problems below -- or perhaps you already have all of them.

Firstly, an "enterprise monorepo" is no longer the collective effort of just a few developers. Enterprise monorepos may contain hundreds of thousands of lines of code -- sometimes millions of lines of code. You might have dozens of Engineers working in different parts of the company, all trouncing on the same repo. Each engineer brings different priorities, perspectives, and proficiencies. You’ll have multiple managers leading different teams, reporting out delivery schedules.

With more code and more developers, the obstacles at this phase are both technical and social. One central questions becomes “how do we break apart the codebase so that different teams can function independently?”

You need to start creating tooling to assign ownership. One solution is to use a CODEOWNERS file, to enforce required approvals when different parts of the repository are touched.

Even more-so than when the app was small, your build times are probably getting bad. It becomes technically-complex to reduce the footprint of the top-level application module, and feature teams may not be incentivized to invest into that effort.

At this point, there are two key technical strategies you can employ to mitigate this.

Firstly, an authoritative voice must decree that all feature teams shall own and publish prebuilt, strongly-versioned library modules. In some-senses, this is moving your organization towards a Mobile-Service-Oriented-Architecture of sorts. (The key difference being that services talk over HTTP, whereas your libraries talk across compilation units.) This strategy yields three key benefits:

  1. The feature library may be consumed and developed by a team-specific top-level application -- a lightweight “dev app" or “sample app." This lightweight app will have limited functionality, and be much easier to work with than the main app itself.
  2. The feature library may be consumed by a different sort of top-level actor: an integration test harness. This will provide a new integration-test entry point.
  3. When the main application consumes the feature library, it will not incur the cost of recompilation.

As with SOA, the details of “how” a library contract is fulfilled is left to the responsible team to determine.

The second approach you can utilize to mitigate app-creep is to invest in an extensibility programme. Namely, you need to develop an application architecture that enables feature teams to build outside of the existing application project.

Act 5: Executing an Extensibility Programme

In order for a feature to live entirely outside of the app module, it needs to be able to call the various dependencies it needs to function -- none of which can be in the app module. In fact, it is impossible for the library module to call back into the app directly: this would create a cyclical dependency.

The first phase of your extensibility program should be to identify the “big lists of things” files. Every app has them. They often materialize as big lists of constants - a routing table, perhaps. The problem with these types of files is that a feature module will necessarily need to integrate with them -- and so will necessarily need to have some degree of footprint in the app.

Such files are usually designed without the open-closed principle in mind. (The author wasn't naive, they were just designing a smaller-sized app at that point. And in that context and time, they actually did the right thing.)

You can often rework such components by either:

  1. Code-generating them. To do this, create build tool that sources input fragments from multiple source repositories.
  2. Adding run-time pluggability. To do so, make an interface for feature components so that they can addPlugin() and removePlugin() (or addRoute(), removeRoute(), etc.)

The other key strategy is to use Inversion of Control (IoC). IoC became popular in large service monoliths. And guess what? We necessarily are building a monolith! At the end of all of this, there's only one app going to the Play Store. IoC dependency containers enable components to speak to one another over abstractions (interfaces), without knowing the details of how things actually will be carried out.

Structure of an Android application that uses an IoC container to separate contract and implementation libraries

In the diagram above, the fictitious “Pizza domain” library needs to use the View/UI contacts so that it can initiate the display of some UI screen. To do so, the Pizza domain takes a dependency on the View/UI contract library. When the application module pulls in its various dependencies, it assigns a concrete implementation of the view/UI contract via service binding.

Each vertical feature (in yellow) publishes a contract library and an implementation library. The application module itself uses a Service Locator to create a binding between contract interfaces and concrete implementations there-of. Ordinarily, this binding code is owned by a supervisory team that actually releases the app, so they can police the list and enforce social policies over it during code review. (In the example above, the binding code lives in the app itself, but could be further isolated into an independent library.)

Act 6. Further Divisions of Source Code

At this phase, we have numerous independently-versioned libraries being published. We have an extensible application architecture that has helped us to prevent growth in our application module itself. Builds are generally faster, and teams can develop and test their features without needing to use the main application module all the time.

By now, you still have lots of thrash on your monorepo. You’re still using CODEOWNERS to help with change control. Should you continue dividing the project further, into separate Git repos? That would surely lead to one of the most decoupled and distributed codebases achievable, right?

There’s overhead in setting up all of these independent repositories, yea, but you could potentially script their creation with some Infrastructure-as-Code-type-tooling. After a while, many of these repositories will be low-touch. Out-of-sight, out-of-mind. Stable APIs will form, and new changes will cease to come in.

The more repositories, Gradle projects, and Gradle modules you create, the more overhead there will be to get work done. But, your teams will be fully out of each other’s way. For the most part.

Handling Dependency Updates

As your application proceeds through these phases of life, you’ll find that managing dependency upgrades will be a pain. When everything is all in one place, you could rely on Herculean efforts to upgrade the single code base. One all-nighter, one Engineer, block out some time on everyone’s calendar, and get the Pull Request in.

That's less palpable as your organization grows bigger. You need additional social mechanisms to plan for changes, and you won’t be able to make them as quickly.

One workflow to execute dependency upgrades across teams might be to broadcast "need by" dates, and ask teams to provide the version numbers of their libraries that will institute the upgrade. Then, the supervisory team (app release team) can create a single PR which updates the dependency versions.

Closing Thoughts

As your app and team gets bigger, there is generally more overhead required to add more structure to it. Don't use a Jackhammer on drywall, and don't use a putty knife on concrete. Only decompose your application when you hit the next incremental limit of scale.

Happy architecting.

💖 💪 🙅 🚩
jameson
Jameson

Posted on September 14, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related