Last week our team learned our mobile app had captured some major industry recognition.
The journey started for me with one of those calls you never want to receive at 5:00 p.m. on a Friday: “Steve, we need all hands on deck to ship [CODENAME] for iOS by end-of-year.”
By the time I called it a Friday, I had learned what [CODENAME] was, and what a deceptively complex problem it was going to be. To start with, we were merging two separate applications built from distinct codebases into one. To make matters more interesting, both applications were going to continue to gain features and bug fixes that needed to be in the new product, because we couldn’t lose the forward momentum both teams had worked so hard to achieve.
All software projects of any complexity have their challenges, and this one had more than its share. Here’s the story of how we came together as a team, and produced an award-winning application out of a pile of mismatched parts.
Since engineers seem to like construction analogies…
We had two fully complete houses: one a quaint three-story Victorian with a dirt basement and the other a sprawling ranch built on a slab foundation. Both houses were functional, and both had their particular strengths, but they had very different methods of construction, and totally different aesthetics. Now we were going to smash them together into one house, while they were both undergoing renovations. We had our work cut out for us.
As you might guess, there were three possible starting points:
- Start from scratch, and migrate “features” into the new shell one at a time.
- Take application A, and “lift and ship” (as management liked to call it) features from application B.
- The reverse, where application B would become the container.
Much like our house analogy, the first option was really a non-starter. The end result might have been better, but there were far too many risks and unknowns for any of us to be comfortable with that path. Despite the fact that most of the engineers tasked with starting the migration were more familiar with application A, we made the choice to start with application B, partly because the appearance was more similar to the end vision for the application, but mostly because the architecture for application B was much “flatter” than application A, and it seemed like adapting A to B would be simpler than the other way around.
We started off with some fairly simple rules. All the compiler warnings were enabled, and we would accept no new warnings; simultaneously we would strive to address the existing issues and finish with a zero-warning build. We would standardize syntax and formatting, and enforce them with swiftlint and swiftformat. Any code added to the new container, whether ported or newly written, needed to come with unit tests and/or snapshot tests. Pull requests and strict code reviews were the currency of the day.
This story is primarily about the iOS version of our application, but in parallel, the Android team was performing the same work, and the Services team was supporting us both. Fortunately, we shared the team responsible for defining the product features, and we shared the design team responsible for the excellent user interface and user interaction models.
In the beginning, a typical feature migration looked like this:
- Identify the feature in the old code base.
- Copy the source code and resources into the new project.
- Try to build it.
- Locate missing code the feature was calling out to.
- Go back to step #2.
One member of our team worked some
git magic, and mashed the two repositories together,
with complete history. This made finding code easier and, thanks to a recent Xcode
feature, made moving files easier. As a bonus, that whole “While the other apps were
evolving” problem became a much less daunting problem, since we carried the live history
for both applications in the new location.
It wasn’t long before we were getting really uncomfortable with the amount of unique “supporting code” that each and every feature brought along for the ride, so we took a step back, and looked at why there were so many tendrils. The short answer is a lot of the older code was depending on a mutable shared global state. To address this randomness, we started to strongly embrace functional core, imperative shell concepts.
This inversion of responsibilities allowed us to use immutable data to initialize our objects, so we could then kill off code that reached out for answers to questions it had no right to ask. Functional concepts helped eliminate code that caused intended or unintended side effects. Once we went down that path, a migration looked like this:
- Identify the feature in the old code base.
- Move the source code and resources to their new locations.
- Try to build it.
- Rearrange code so all things are present at initialization.
In addition to lifting code, the combined application had a whole new appearance and user interaction model (or as the non-engineers called it, “a light re-skinning”). While this could have been a nightmare, the cooperation and collaboration of several teams made it fairly painless.
We started off with the design team describing things not a pixel at a time, but through a “Native Design Language.” They described in abstract terms how elements looked and behaved, and then we made the application look and feel that way. For consistency (and sanity) we created an “NDL.framework” which implemented all of the UI building blocks. This made creating new screens very simple, updating existing screens quite a bit simpler and, probably more importantly, made everything consistent.
Sometimes the designers would ask for things that didn’t feel “native,” and when we pointed that out, they would come back with a solution that worked better, every single time.
Likewise, some of the feature details were a little too ambitious for our timeline. Our product folks were very receptive to discussions like, “Your favorite feature is going to take 10 days, and I don’t think we have that much time. I could give you [SOME SUBSET OF THAT FEATURE] in two days, and then we can follow up early next year.” In every case I can remember, we were able to come to a compromise that we could all live with.
I’d be remiss if I didn’t mention our top-notch QA team. They started testing features as soon as they were running, even while the application as a whole was unusable. With their early, eager engagement, we avoided the all-too-common QA/bug fix infinite loop that plagues many projects of this scope.
So in the end, we have a robust application (iOS is over 99.9% crash-free sessions) built on a solid foundation. This sets us up for accelerating new features without putting the overall application stability at risk, and without slowing progress due to increased complexity. The future looks bright!
If you are passionate about creating high-quality software, I’d love to have you join me and the rest of the mobile team here at American Express. Our Careers site lists multiple positions for mobile engineers and many other interesting roles.