This article is part of a series.
I’ve started working on this project as the only engineer and later technical lead in January of 2021. Being the only technical person in it from the start gave me freedom in choosing how to technically proceed with this massive company-wide project. It has been a blessing and a curse. I could do things my way but also anything that doesn’t go well will be attributed to me. That was enough motivation for me to try to make it as seamless as possible.
Before we started hiring people for this specific project (as explained in the previous article), I already started thinking about how to technically deliver redesign. But there were some constraints. Specifically:
The new people did not know the codebase yet. This was understandable since they were hired for this project but nevertheless, a complication.
The timeline of the project was very short for its scale. For the first month of this year, it was only me in the project and the task was to rebrand every single part of the product.
We could not create a brand new app as certain functionality required us to keep the same app id on both iOS and Android (device binding, ethereum wallets, etc.), and since it might degrade UX (password managers wouldn't fill in the login form, etc.).
The web client has a much steeper timeline than native apps due to the fact that we were only going to hire people on the web part much later.
Those have been the constraints we needed to work with. And as you can imagine, they meant we couldn't do things in a perfectly orderly manner but we needed to consider all of them before jumping ahead.
When doing any migration, you look at two models:
How are things currently looking (Current state)
How you want things to look (Desired state)
The process between the two is the ideal migration. Do it poorly and you won't arrive at the second.
Lowest possible diagram of a migration
It’s a simple and very high-level example. But it helps a lot to think about both of the models separately, figure out the constraints and possibilities, and only after to think of the specific technical implementation of the migration.
For the current state, it's helpful to have that described in documentation or at least have a good picture of it in your head. All that is possible only if you have very good documentation or a person (or people) who has been involved in building the product in the first place. It doesn't matter how much you know about the final state if you don't know the current all throughout.
The desired state needs to be clearly defined as well. Otherwise, you might end with a different result than you expect. And especially working with multiple teams, poor planning may result in creating a Frankenstein monster of a product. In rebranding projects that’s very clearly visible when different features of the same app look inconsistent with each other. Mostly due to the development of the different features being segregated into different teams.
There’s usually some resemblance of that even in well-planned redesign projects. In the best-case scenario, that’s just a few loose pixels and not the app breaking or massively inconsistent experience.
To expand on the simple migration diagram above, in our case, we needed to conserve the current state also as much as possible. Due to the limitation of having to preserve our current app.
All our changes during the development of Nuri were already published to production so the standard of quality for our work was as high as in any other released user-facing feature.
Even though the rebrand wasn’t released at that point, the Nuri app lived under the hood of the Bitwala app for months without anyone noticing.
This also posed unique challenges since the development of other features in the app didn’t stop. We couldn’t make a copy of our current app and migrate that. The existing flows were still changing under the development of the rest of the engineers in the company. If we’ve done a copy, we’d end up with a redesign of a few months old app without the new features and bug fixes.
Our desired state, therefore, included also all the work that was going to be done outside of our project. We couldn’t break other people’s work and their work couldn’t break ours.
Enough of justifying project decisions. Let’s get down to the actual implementation details.
Migrating Bitwala To Nuri
So the constraints were clear and we had a reasonably good idea of how the entire product structure is from the technical point of view. Some other things were known. We needed to work on the same app as everyone else without affecting their work. We also needed a simple way to switch back and forth in the build process.
With this, it's simple to just change this back and forth. Tough, we needed this flag to also be reactive. We needed that for two main reasons.
The ease of development. Developers could switch back and forth between Bitwala and Nuri easily without rebuilding the app. Therefore saving time on local testing.
Rollback plan. In the worst-case scenario, we needed to be able to switch from Nuri back to Bitwala without the need of releasing the app to app stores again, which might take a few days.
Since the environment variables can't change during the app runtime, we had to create a proxy for that. Another layer that'd read the flag but could become independent of the flag if we needed that.
Our app uses React Native. So this required using a react context so we can have a reactive variable isRedesign
available everywhere in the app and we could easily switch it whenever we needed.
import React, { Dispatch, SetStateAction, useState } from 'react';
/**
* Read the flag from environment.
*
* This line is not exported to not confuse developers it shouldn't
* be used outside of this file.
*/
const isRedesignFlag = process.env.REACT_APP_REDESIGN === 'true';
/**
* Create react context to store and override redesign flag
*/
export const RedesignContext = React.createContext<{
isRedesign: boolean;
setIsRedesign: Dispatch<SetStateAction<boolean>>;
}>({
/* Set the context variable to environment value by default */
isRedesign: isRedesignFlag,
/* Default method, will be initialised later */
setIsRedesign: () => {
//
}
});
/**
* Context provider for redesign flag. This is the place where the provider
* is given reactive properties.
*/
export const RedesignContextProvider: React.FC = (props) => {
const { children } = props;
const [isRedesign, setIsRedesign] = useState<boolean>(isRedesignFlag);
return (
<RedesignContext.Provider value={{ isRedesign, setIsRedesign}}>
{children}
</RedesignContext.Provider>
);
};
Then using the context to wrap the entire content of our app, including any parts that are fetching data from our server, dealing with translations, deal with in-app navigation, etc.
export default App: React.FC = () => (
<RedesignContextProvider>
<AppContent />
</RedesignContextProvider>
);
With this bare-bones setup, we were ready to start implementing. The developers didn't need to worry about the deployment process at all, they could've used the context to tell the redesign flag even if it was changed from within the app and not during app build.
This process was the same for both our native app and web since we use the same technologies for both platforms. But the redesign wasn't a monolith so there were some particularities in each of them.
Component & Screen Migrations
Redesign projects are very visual. We have an internal UI component library for native apps and for web app. The native and web libraries are separate but the approach with which we've migrated the components was the same for both.
The criteria here to easily migrate and keep the current components intact as much as we could (to prevent bugs in the migration). To do this, we've contained the old components in their own files.
To give an example, before our folder structure for a Button component looked something like this:
.
└── components/
├── ...
├── Button.tsx
└── ...
We've created a folder for the component first, and then renamed Button.tsx
to Button.deprecated.tsx
but left the content exactly the same. Then we were free to create a new Button.tsx
file where our new component would live.
.
└── components/
├── ...
├── Button/
│ ├── Button.deprecated.tsx // Old Bitwala component
│ ├── Button.tsx // New Nuri component
│ └── index.tsx // Migration file
└── ...
In index.tsx
we've kept our migrations. The external API of the components might be different for the old and new components but we made sure that any new properties were still working for the old components (even though they might not have any effect there) and old props were still migrated into the new component and overridden if needed.
import React, { Dispatch, SetStateAction, useState } from 'react';
/**
* Migration for Button component
*/
export const Button: React.FC<ButtonProps> = ({ ...buttonProps }) => {
const { isRedesign } = useContext(RedesignContext);
/**
* Here we've migrated any properties that were not interchangeable
* between the old and the new component.
*
* E. g. The colors of the Buttons have changed in Nuri, here we
* could override even hardcoded values across the codebase safely
* without going through the codebase and changing every instance.
*/
/**
* Conditionally rendered component old/new based on the redesign flag
*/
return isRedesign ? (
<ButtonRedesign {...buttonProps} />
) : (
<ButtonDeprecated {...buttonProps} />
);
};
This way our components could've been considered feature-frozen. We didn't touch them at all after this and therefore we were sure that we're not breaking existing flows. The only thing we needed to test thoroughly still were the migrations themselves.
For the screen migrations, we went with a very similar approach for a lot of the screens. But screens required a more case-by-case basis approach.
After the component migration, most of the screens were already visually half-migrated which saved us a lot of work. But the navigational structure of our main screens has changed quite a bit. Before we already had separate bank and crypto portfolio screens. But now we've had a brand new Home screen that combined both into one “at a glance” screen. Also, the tabs for Invite and Trading disappeared as invite functionality has been moved into Profile and trading functionality went under crypto portfolio screens directly (or what we now call Wealth).
With so many changes in the main screens, we migrated the entire section and built that from scratch with our new components and new logic for fetching data, etc.
As many other screens were already half-migrated after component migrations, we didn't need to do too much there.
A lot of the functionality stayed the same in many flows. These flows kept their old logic and were migrated only visually. But nevertheless, our aim was also to improve the UX of certain flows and so some of them required more than visual changes.
This article could've gone way longer. There's definitely a space for more technical information about how we went about deploying all this on each platform. And also how exactly we went through all the different scenarios of migrations around our codebase. But I'll leave that be for now.
This article is part of a series.