Skip to main content

Our world is always changing. What worked in the past might not work now, or in future situations. Over time our needs will change, and we will need to adapt to meet them.

As developers we often find ourselves refactoring code to enhance our app’s performance, or to cater for new requirements. Refactoring is not always an exciting task, but the results are often rewarding.

In this article we will guide you through our process for modifying a legacy codebase to replace a component with a new implementation. Please note, we provide this article as a guide only and not as a solution for all circumstances.

One of the principles of the Agile Manifesto is:

Deliver working software frequently, from a couple of weeks to a couple of months, with a preference to the shorter timescale.

Similarly, our approach in this guide will be small changes at a time, iterative, and slowly disruptive.

Isolate the work

When starting a refactor we ensure the work is done in a separate, dedicated branch. We only commit changes to this branch related to the refactoring and don’t mix in any other changes. If we included other unrelated changes, we risk breaking or conflicting with changes made by other teammates when our branch is merged.

Other benefits of keeping the refactoring work focused are that the pull request & code review will hopefully be smaller, and that the branch can be easily reverted as a single unit if it turns out that bugs or other unintended changes have been introduced.

Provide good test coverage

One of the principles of code refactoring is that the external interface or behaviour of the code should not change. Before making any changes, we create or add additional tests that verify the existing behaviour.

Having good test coverage for the old implementation gives us the reassurance that the logic of the new implementation will work as expected with the rest of the app’s codebase. It also means that our new implementation will have good code coverage from the beginning.

Keep, but deprecate, the existing legacy code

Before we create a new implementation, we will start by deprecating the existing implementation.

In Swift, we use @available attributes to indicate that the legacy type or methods should not be used. Eg:

@available(*, deprecated, message: "AppLinkReturnToRefererControllerDelegate is deprecated and will be removed in the next major release")
protocol AppLinkReturnToRefererControllerDelegate {
    // ...
}

extension PrimitiveSequence {
    // ...

    @available(*, deprecated, renamed: "observe(on:)")
    public func observeOn(_ scheduler: ImmediateSchedulerType)
        -> PrimitiveSequence<Trait, Element> {
        observe(on: scheduler)
    }

    // ...
}

When replacing a legacy Objective-C implementation we use the  DEPRECATED_ATTRIBUTE and DEPRECATED_MSG_ATTRIBUTE macros defined in AvailabilityMacros.h. Eg:

FOUNDATION_EXPORT NSErrorUserInfoKey const FBSDKGraphRequestErrorCategoryKey DEPRECATED_MSG_ATTRIBUTE("use FBSDKGraphRequestErrorKey instead");

DEPRECATED_MSG_ATTRIBUTE("FBSDKAppLinkReturnToRefererControllerDelegate is deprecated and will be removed in the next major release")
@protocol FBSDKAppLinkReturnToRefererControllerDelegate <NSObject>
// ...
@end

We can make use of the @Deprecated annotation in Kotlin, similar to Swift’s @available attribute. This annotation has the option to provide a suggested replacement for the old legacy code. Eg:

@Deprecated(
    message = "Call getRectangle() height property instead",
    replaceWith = ReplaceWith("getRectangle().height"),
    level = DeprecationLevel.WARNING
)
fun getRectangleHeight() { return rectangle.height }

For Java, you can use a similar @Deprecated annotation. Replacement suggestions can be added inside a Javadoc comment above the annotation. Eg:

/**
* @deprecated
* This validator is replaced by third-party API
* <p> Use {@link com.third.party.Validator#validatePhoneNumber()} instead.
*/
@Deprecated
static void phoneNumberValidator() { }

Using annotations in this way allows the compiler to highlight all the places where legacy code is used with deprecation warnings.

We will sometimes also rename the legacy types to make it clearer that they are old legacy implementations and should be avoided.

Branch by abstraction

An excellent technique for incrementally replacing legacy code is Branch by Abstraction. After deprecating the existing code we will introduce an abstraction, such as a protocol, factory or strategy pattern, around the legacy code so that a new implementation can be dropped in later without changing client code.

One of the benefits of Branch by Abstraction is that it requires us to keep the existing legacy code as we incrementally create a new implementation, avoiding the situation where the application is broken or does not compile.

We can reference the existing code if we need to, write additional tests if we discover new requirements and apply other refactoring techniques as we write the new implementation. This new implementation will have to conform to the abstraction we’ve defined and pass the existing unit tests.

Once the new implementation is complete, has been tested and proven to work as expected, we can make the new implementation the default.

Communicate with the team

As the abstraction and new implementation comes into the project’s main branch, we will let other team members know about it so they can start using it.

This is an important step, as our ultimate objective is to completely remove the legacy code from the codebase. We can only do that if everyone stops using it and knows how to use the new implementation correctly. The deprecation annotations that we’ve added to the legacy code will help highlight to our team when they are using the old implementation and encourage them to adopt the new implementation correctly.

Update the documentation

When a project has documentation that is affected by the changes we’ve made, we’ll ensure the documentation is updated accordingly. Documentation should always be consistent with the code in the repository.

Integration test

As a general precaution and good practice, we always test the new implementation in an integrated fashion. We will do this by using automated tests such as XCUITest for iOS or use the Espresso testing framework for Android. Other than automated tests, performing manual regression tests can suffice.

Automated tests and manual regression tests have their own strengths and weaknesses. We normally do a combination of both to maximise the test scenarios and edge-cases that we can cover.

Multiple small changes are better than one huge one

Depending on how large the project is and how widely the component we’ve refactored is used, this process can result in a gigantic change that can be a nightmare for fellow team members to review.

Therefore, we try to avoid this by breaking down the refactor into smaller steps, with each step having a dedicated pull request.

Remove the legacy implementation

After the new implementation has proven itself, we can remove the legacy code. The abstraction we used to give us the ability to swap between implementations may also no longer be required and we can remove it to simplify the code.