How the Medium iOS team works effectively with legacy code

This story is not about pretty code, it is even less about pretty design.

Zouhair Mahieddine
Medium Engineering
11 min readJul 29, 2024

--

By Ed Uthman — originally posted to Flickr as Apple I Computer, CC BY-SA 2.0, https://commons.wikimedia.org/w/index.php?curid=7180001

The Medium iOS codebase is more than 10 years old, and we still have code dating from 2013 and 2014 that is still in use today.

Here you might think why the hell did they never completely rewrite this?

This is actually something we wear like a badge of honour in the Medium iOS team and I want to explain why today, by diving into how we work effectively with legacy code, without it getting (too much) in the way.

Working effectively with legacy code

I consider Michael C. Feathers’ 2004 book “Working effectively with legacy code” an essential read and a very handy reference book for any engineer who strive to be a pragmatic programmer.

Structured in two parts, the first one more theoretical (why software changes, how to model changes, etc.) and the second one very practical where each chapter is its own little “refactoring” scenario, this book is one I come back to very often for guidance.

At Medium, we find ourselves in such scenarios pretty much all the time, and sometimes with very old legacy code, written by people who contributed and left long ago, using patterns and facing constraints and restrictions which don’t exist anymore in modern iOS development.

Let’s look at some of these real life scenarios, and how we approach them.

Changing software

I don’t have much time and I have to change it

Code is your house, and you have to live in it¹

One of the most legacy piece of code currently powering the Medium iOS app is our story renderer. It was partially rewritten circa 2019, a rewrite which led to its own complexities I won’t detail here (leave a response if you might be interested in knowing more about this), but kept the same UIKit rendering engine and overall design since it’s tied to the way we model story content and metadata.

Due to this modeling, our story page is composed of three distinct parts:

  • a header containing metadata about the story (title, author, etc.)
  • the content itself, composed of text, images, medias, etc.
  • a footer with more metadata (associated topics, etc.) but mostly recommendations on what to read next
Story page header and footer

While the content of a story itself never really changes and we are satisfied with this legacy code, we often have to implement new features or evolve existing ones in the header and footer parts of the page.

To be able to do so without having to modify the legacy code handling the story content, we use a technique described in Working effectively with legacy code as Sprouts (Sprout method or Sprout class)

When you need to add a feature to a system and it can be formulated completely as new code, write the code in a new method. Call it from the places where the new functionality needs to be. You might not be able to get those call points under test easily, but at the very least, you can write tests for the new code.

A very recent example of the power of Sprouts is when we needed to introduce some new recommendation sections to the story page footer, to show users more stories from the list to which the currently shown story belonged. Given this ability we have to build UIs outside of the story page renderer and then embed them, we were able to build this entire recommendation section in SwiftUI (and in its own SPM module), and then embed it into the UIKit renderer as a new element of the footer.

New “More from this list” recommendation carousel in the story page footer

I don’t understand the code well enough to change it

Everyone runs into demons that they can’t slay from time to time

Another very common example of how we have to deal with legacy code is when something is working correctly but we need to evolve it nonetheless.

This happened a couple of years ago with our handling of Dark mode. The Medium iOS app had been supporting its own internal Dark mode feature since before iOS officially supported it. This led to a very complex and code-heavy system where each UI element supporting Dark mode had to subscribe to appearance changing events and react to it by “manually” updating all of its colors. The workings of this can be represented like this:

The Medium iOS app dark mode support prior to iOS official support

On the schema above, all red boxes represent parts of the system which need to know the currently selected appearance (light or dark).

When Apple finally introduced official support for Dark mode on iOS with iOS 13 in 2019, the team in place at Medium at the time decided to build this support on top of the existing custom design shown above. The representation then became this:

The Medium iOS app dark mode support after iOS official support

This diagram is pretty similar to the previous one, except for the ability to react to system-wide appearance changes. And as you can see, most of the system still needs to know the currently selected appearance (all the red boxes).

We then lived with this “hybrid” system for a couple of years, which led to several UI issues due to desync between the system-wide appearance and the in-app one, as well as a lot of maintenance and extra work (every new feature needed to explicitly subscribe to and support this system).

This was not ideal and we decided to change it. I’ll satisfy your curiosity by showing you our current design below, but my goal here was to highlight the process with which a team who was not involved with any of those two iterations was able to build a clear understanding of that feature, which then informed how we wanted to refactor it.

When reading through code gets confusing, it pays to start drawing pictures and making notes. […]

Don’t be afraid to take time to sketch out how you think an existing system works while you are exploring it. Your initial assumptions might be wrong and you’ll go back and edit your sketch but after a few iterations, you will have built a solid and actionable understanding. And then, you will feel way more confident going in and changing that feature’s code.

Ok, so here is what the Dark mode system looked like in the end once we decided to embrace the iOS support and rely on it instead of on our own custom code:

The Medium iOS app dark mode support today

Note how few red boxes we have today. iOS is doing most of the heavy lifting which led to way less code, by-default dark mode support for any new feature and no more desync bugs.

My application has no structure

Architecture is too important to be left exclusively to a few people

As discussed in a previous article by my colleague Thomas Ricouard (see Evolution of the Medium iOS app architecture), if you want your codebase to become more structured and stay that way, launching a months-long “rearchitecture” project is probably not the best idea. Instead, try to put the minimum structure in place that will encourage anybody on the team to follow that structure and add to it, without constraining them too much.

For us, this meant providing a template for modularisation, in the form of our local Swift packages.

Today, any engineer in the iOS team working on refactoring an existing feature or building a new one will find in our modularised architecture both an incentive to build that work into a module (because we made it simpler and faster to do so than to write code in our legacy monolith) and also a range of examples of how to do it, based on our existing modules.

Thanks to this, we don’t even have a policy in place that you should not write new code in the legacy monolith, because we know (with the way our project is structured to push you to modularise) that if someone chooses to write in the legacy monolith, it’s because they have a very solid reason to do so. And trust me, it rarely happens anymore.

This class is too big and I don’t want it to get any bigger

The structure you have in your application works. It supports the functionality; it just might not be tuned toward moving forward.

This scenario is one we are confronted with almost every time we need to touch code in our legacy monolith, simply because it is filled with giant classes mixing a lot of stuff together in very large files. I am sure that sounds familiar.

For example, It happened a year and a half ago when we wanted to add new settings to our in-app push notifications subscription settings screen which meant we had to rework the feature both at the API level (introducing new GraphQL endpoints) and at the UI level.

Push notifications subscription settings screen in the Medium iOS app

The previous settings screen was working correctly but was completely embedded into a very big Settings class handling a lot of different stuff.

Using our ability to easily build modules, we extracted the existing push notifications settings screen into its own module, made sure everything was still working correctly, and then started refactoring and restructuring it. To do so, we used a technique described in Working effectively with legacy code as Seeing responsibilities

In real-world cases of big classes, the key is to identify the different responsibilities and then figure out a way to incrementally move toward more focused responsibilities. […] there really is little difference between discovering responsibilities in existing code and formulating them for code that you haven’t written yet. […] If anything, legacy code offers far more possibilities for the application of design skill than new features do.

Once those responsibilities were identified and extracted, it allowed us to move forward step-by-step with the refactoring, without needing to think about the many crazy ways our changes could affect the rest of the legacy Settings class, because it couldn’t anymore.

How do I know that I’m not breaking anything?

Most materials that you can make things from […] fatigue. They break when you use them over time. Code is different. If you leave it alone, it never breaks. […] the only way it gets a fault is for someone to edit it.

I kept this scenario for last because it’s one of my favorites.

More often than not when trying to work on legacy code, we find ourselves in a situation where some legacy piece of code we want to replace is used in lots of places for different use cases. This is especially true when this legacy code is a dependency (e.g. a third-party library) we are trying to get rid of.

This happened to us a few months ago when, in an effort to try and stop using Cocoapods in the Medium iOS codebase, we needed to get rid of a pod named KSDeferred which is an Objective-C async library inspired by JS Promises.

We wanted to hot swap this library with simple completion-block based async code directly in the Objective-C code referencing it. To do so, we used two of my favourite techniques from Working effectively with legacy code:

  • Preserve signatures: this technique is best used in systems where you don’t have tests in place to safely change code, but instead you need to refactor a bit just to make the system testable enough. It makes hot swapping significantly less error prone.
  • Lean on the compiler: the key thing about this technique is that you are letting the compiler guide you towards the changes you need to make. After all, why do extra work a machine can do for you?

In our example of removing KSDeferred, this meant doing two things: first preparing our new code to have the same signature as the one using the library, and second unlinking the library so that the compiler would complain and we would know all the places where we needed to go and swap the references to KSDeferredto our own new ones.

Here is an example of how applying these two techniques looked like for that project.

Initially, the code using KSDeferred looked something like this, with a method returning a promise, and a caller handling it:

// Async method returning a promise
- (KSPromise *)deferSomeWork {
KSDeferred *deferred = [KSDeferred defer];
// do some work, potentially return a deferred error.
return deferred.promise;
}

// Caller expecting a promise
- (void)someOtherMethod {
[[self deferSomeWork] then:^id(id value) {
// Everything went well, read the value and continue
} error:^id(NSError *error) {
// Handle deferred error
}];
}

What we first did was use Preserve signatures to create a similar method without using a KSDeferred promise (note that this method’s declaration looks different, but it’s the way it can be called that’s preserved) :

// New async method using a completion block
- (void)deferSomeWorkThen:(void (^)(id value))thenCallback
error:(void (^)(NSError *error))errorCallback {
// do some work
if (someError != nil) {
errorCallback(error);
} else {
thenCallback(someValue);
}
}

With that method in place, we can now just delete the original — (KSPromise *)deferSomeWork method and the compiler will tell us exactly where to replace it with our new one, by throwing errors!

And the final code then looks like this (notice how similar it is to the old one, which helps prevent errors):

// Caller, now without promise
- (void)someOtherMethod {
[self deferSomeWorkThen:^id(id value) {
// Everything went well, read the value and continue
} error:^id(NSError *error) {
// Handle error
}];
}

Voila! If you’ve never tried these techniques, I encourage you to give them a go, they provide clear guides to help prevent errors, and they feel extremely satisfying.

To rewrite or not to rewrite

I held off asking this question until the end for a reason. More often than not, the first idea that will come to a team’s mind when facing legacy code is we should just rewrite the whole thing from scratch.

If you read this far, it won’t come as a surprise to you that us the Medium iOS team consider rewriting from scratch a last resort solution we almost never use, and this for two main reasons:

  1. It’s harder to read code than to write it, as Joel Spolsky very clearly puts it in his blog article Things you should never do, which I encourage you to read
  2. Rewriting projects very easily fall into the Sunk Cost Fallacy

Instead, in our team, we try to focus on all the ways we can keep legacy code in place while reworking bits of it or even working around it. This way, we learn more and more about the intricacies and small subtleties of that piece of code and, once we’ve taken it apart enough or understand it enough, then we consider rewriting what’s left and/or the whole thing, using the more modern bits we introduced over time.

As Michael C. Feathers puts it in the Preface to Working effectively with Legacy Code

Good design should be a goal for all of us, but in legacy code, it is something that we arrive at in discrete steps.

And that’s what I’d like to leave you with, don’t underestimate the power and effectiveness of discrete steps.

¹ All quotes in this story are from Working effectively with legacy code, except noted otherwise.

--

--