Rewriting a large multi-domain UI application

by Alfredo Bermudez, and Florent Garit, Frontend Engineers at Cisco ThousandEyes

Bringing some sanity into code repository sharing

If you’ve ever shared a codebase with multiple teams, you may have faced this sort of problem: code getting entangled, bugs in your domain caused by changes made by other teams, among others. You might even have considered breaking up your code into multiple pieces. In this blog, we will evaluate some strategies to help you break up your codebase. We’ll then present the solution we adopted with a simple trick that’s helped us make it work seamlessly.

Moving beyond the monolithic web app

The ThousandEyes platform gathers a lot of data through our Cloud Agents, Enterprise Agents, Endpoint Agents, and BGP monitors. Our customers use this data to troubleshoot various problems, from pure network issues to application layer troubles. To catch problems early, they subscribe to alerts. And when troubleshooting, we offer them the ability to either pull the data ThousandEyes gathers through a public API or visualize it through our web application.

We call it “webapps.” It is a piece of our monolithic past that ThousandEyes’ engineering has and continues to put great effort into breaking into micro-frontends for a more distributed and scalable architecture. This web application has been around since the inception of ThousandEyes. And over the years, we’ve added more and more products to it.

Since most of our products offer similar UI/UX, we reuse/factorize much of the code. For example, all our products have at least one form-based page to configure. Then, most have a page called “views” to visualize metric-based data over time. Users can select specific time ranges on a timeline to show the breakdown of information for that time range.

Over time, our developer teams have all added their bit of specific functionality to an otherwise shared codebase made of hundreds of angularJS/VueJS components, hundreds of services, and a gigantic shared redux state. To top it all off, most of the code was untyped.

We came to the point where the codebase became extremely difficult to work with, resulting in unexpected consequences in a totally different part of the app whenever we made a change. This environment was perfect for bugs to proliferate if not carefully managed. A robust set of E2E tests was the only way to ensure the app’s integrity.

Longing for a better organized, better-isolated code base

We on the Cloud and Enterprise Agent (CEA) platform team maintain and develop the product frontend. One day, the Product team introduced big plans for new features to the product’s data visualization page, called the CEA views. However, it quickly became apparent that we wouldn’t be able to modify the current web application to add those new bits of functionality. So we took advantage of the situation to go ahead and extract this page into its own shiny, micro-frontend.

Our primary goal with this endeavor was to empower us developers to deliver new functionality to our customers faster and with fewer bugs. To achieve that, we wanted to build a codebase that is better organized, better isolated, and with an overhauled tech stack. It would use Vue3 as the UI framework and Pinia (the new VueX) for store management. Most importantly, we would type our new codebase entirely via Typescript. Somehow, Typescript has only somewhat recently become popular in the frontend world, but it is an absolute game-changer for maintaining, upgrading and debugging code. An added bonus of this upgraded tech stack is that the new app is much faster and responsive, allowing us to offer more information on a screen.

The challenge

Though we were building a new micro-frontend isolated from most other products, our product has the specificity of encompassing data from four different domains owned by four different teams. So we faced the following challenge: how can we build a new visualization application that is shared across multiple teams/domains yet maintains sound isolation?

Single repo, multiple repos, libraries, micro-microfrontends?

Before we could do anything, we needed to solve the domain separation problem. We found multiple solutions for it. Some were fairly straightforward, others more Inception-like. They were as follows:

  • Micro-Frontend in a Micro-Frontend
  • Single code repository with domain-specific directories
  • Domain-specific npm packages

Each of these had its pros and cons. Still, in the end, the goal was to strike a balance between development velocity, build complexity, domain code isolation, code duplication, and code decoupling.

Micro-Frontend in a Micro-Frontend

Let’s start with the one with the most buzz-wordy name. But, first, what is a micro-frontend?

The idea behind Micro Frontends is to think about a website or web app as a composition of features which are owned by independent teams. Each team has a distinct area of business or mission it cares about and specializes [sic] in. https://micro-frontends.org/

Micro-frontends are all the rage at the moment, and understandably so given the problems they solve. In theory, they allow for faster and more independent releases with completely decoupled and isolated code.

In our case, we would need to load a micro-frontend inside another micro-frontend. This process in itself is not an issue. For us, the problem with this approach is that there is no clear demarcation, product-wise, of where these micro-frontends start and end. Instead, individual teams own different pieces of the UI spread across a single view.

Say we solve this by having multiple micro-frontends per domain. They would still need to communicate with each other, which means implementing a custom event system. Ultimately, this approach would have exponentially increased the complexity of our builds and dev environments.

Single code repository with domain-specific directories

Another approach is to have a single repository split into domain directories. Allowing each team to own their respective Pinia stores (and would have access to some shared ones) and provide the CEA team with some Vue components and configurations. This approach is simpler in implementation but has its own setbacks. The access permissions for the shared state, getters, and actions need to be clearly documented. Enforcing this programmatically is not supported out-of-the-box, and we did not intend to implement this.

So we came up with the following solution: folders would use a naming scheme to distinguish domain-specific code from shared code and its access permissions.

…and split states

A variant of the approach above is to share the state more explicitly instead of allowing direct access to the shared Pinia stores. A record of getters and setters for the state would need to be provided to the domain teams. This approach doesn’t have the previous state permissions-related issues, but it has some possible performance implications, and there will be more developer overhead. Documentation or naming schemes might not be needed, but there will definitely be more code.

Domain-specific npm packages

Finally, we could adapt the previous approach so the domain-specific components can be packaged into npm packages owned by each individual team. This approach brings one main benefit, code encapsulation. Moreover, we’d solve issues with code access. On the other hand, we would add a lot of development overhead. The components provided will require a well-defined API. In this case, the code would be separated but completely coupled.

A simple yet efficient, visual way to ensure code stays separate

Ultimately, we went for the “single-code repository with domain-specific directories” approach. But, as we mentioned before, this method means we must clearly document access permissions. So, instead of depending on READMEs that no one ever reads or updates, we went for a slightly different approach: We started documenting ownership and permissions in the folder names themselves.

Even when we tried to keep domain-specific code in the domain-specific directories, files still contained shared code. In these cases, we would use the following naming scheme for the directories:

fileName.[owner].[permissions].

The owner will be the team in charge of that piece of code.

When there is no clear ownership, we use ‘common’. This name is the case mostly in shared states or utilities. Then, after the ownership part, comes the access permissions pattern that describes what other teams are allowed to do in that folder structure. In practice, there are only two possible values: ‘r’ for read and ‘rw’ for read-write.

You may be thinking right now, what is the benefit of this? Well, perhaps an actual code example speaks for itself. As you’ll see from the imports below, it is very easy to spot the owner and permissions.

import RequestStatus from '@/pages/views/store.common.r/constants/RequestStatus';import { MetricId } from '@/pages/views/constants.common.r/metrics/MetricId';
import { MetricId } from '@/pages/views/constants.common.r/metrics/MetricId';
import { CeapTimelineDataRequest } from '@/pages/views/domain/ceap/stores/timeline/models/metricTimelineDataModels';import errorHandler from
'@/services.common.rw/errors/errorHandler';
import { isCeapExtraTimelineDataType } from '@/pages/views/domain/ceap/stores/timeline/models/CeapTimelineOverlayDataType';

*Common files are bold while domain files are not bold.

Limitations

There is one big limitation with this approach. There is no out-of-the-box automated way to prevent a team from using the other team’s code. Instead, we need to enforce this through Pull Request reviews. But we have one thing to our advantage: it’s pretty easy to catch an import that belongs to another team in a Pull Request.

import { CeapTimelineDataRequest } from '@/pages/views/domain/ceap/stores/timeline/models/metricTimelineDataModels';import { BbotTimelineDataRequest } from '@/pages/views/domain/bbot/stores/timeline/models/metricTimelineDataModels';

Team cooperation

We’ve worked with this framework for a few months, with several teams actively developing within the same codebase. So far, this system has been working to keep the codebase organized. To what extent, you may ask. The best measure is how many exceptions we’ve made to the rules, which is just a handful. And this is in a codebase with 10+ contributors from four different teams. Not too shabby.

Conclusion

In undertaking the gigantesque task of rebuilding a large part of the ThousandEyes application, we were able to isolate our codebase from most other domains. However, we still had to devise ways to deal with our product directly involving multiple teams. After considering our options, we found a solution that worked really well for the team, even if imperfect, and enabled us to maintain order in the new codebase as we rapidly scale.

At times, we get hung up on trying to find the perfect solution with ideal tooling and automation when a straightforward, simple one can do the trick without adding complexity to our build and projects.

--

--