Evolving an AngularJS application using microfrontends

--

Photo by Matt Howard on Unsplash

It’s natural that, as passionate front-end developers we are, we aspire to be always using the best practices, tools, and solutions that helps us to be productive, deliver high quality software and not reinvent the wheel. No one wants to be stuck in an old stack forever. And within ContaAzul is no different. All we want is to be able to put our constant learning into practice in order to keep our continuous wow delivery.

However, reality is not always that pretty and sometimes you have to get used in spending a lot more time than you expected to get things done. Our application grew up over time, more and more developers started to maintain it and, at some point, we realized that it was not scaling. Angular (mainly the oldest versions) has issues that we are not satisfied with, we are still doing workarounds to improve things that are currently already solved in a better way. So, what would we like to do?

You may think we should abandon the legacy code and start a new application from scratch. But sometimes things can be just a little more complicated, mainly when you have a very large application used by a lot of people. Stopping everything and rewriting it is not an option for us. Our application actually delivers a great value to our customers and we need to keep doing that. We cannot simply stop delivering new features to rewrite something that already exists… and works. But this cannot be an excuse for us not to do a better code, right? So now I’ll show you how we started scaling our application and progressively get rid of our old technology stack.

First, what are the main problems?

  • Lack of reusable components
  • Some pieces of code are hard to maintain
  • Huge amount of tests running slowly
  • Tech stack very hard to update
  • Releasing is a boring and slow task

Ok, we have a legacy code (who hasn’t?) and it’s not one of the bests, but now we can do better. So how we can start doing a better code without spending a lot of time creating more workarounds or rewriting things that we not necessarily need to rewrite now? We decided to create Strangler Applications:

[…] An alternative route (to rewrite a system entirely) is to gradually create a new system around the edges of the old, letting it grow slowly over several years until the old system is strangled (Martin Fowler)

That’s it, microservices! Or by extending the microservice idea to frontend development: microfrontends. Using this concept we can start breaking our huge monolith into smaller pieces that would coexist until the host is strangled. By doing this, we can stop making the Angular monolith bigger and even more complex and start doing a healthier code outside of it.

To put this into practice the team had some discussions about which Javascript framework fits better to our context and after that we decided to use Vue. One of our motivations to use it is that for us Vue is like a mix of React pros (components, performance, etc) and Angular’s familiarity. Anyway, this is a topic we can discuss deeply in another post.

Our goal here then is to create satellite apps in Vue around our Angular monolith. We did this using routes. Considering we have a Single Page Application, the routes informed by the URL are resolved with Javascript. For this purpose, Angular has ngRoute and Vue has vue-router. Both do basically the same thing: they watch the URL in the browser and choose what to render. Using ngRoute, we declare the route and which template and controller should be called:

Similarly, with vue-router, we declare the route, and the component to be rendered:

Having that in mind, the main idea is that sometimes both ngRoute and vue-router can manipulate the URL “at the same time” in the same application. In order to achieve that we create a kind of gateway inside the Angular monolith, which will decide if each route should be rendered by itself or by some microfrontend.

Basically, we have to do two things:

  • Create a gateway inside the Angular monolith;
  • Create a complete Vue application with its own vue-router, that exports two main methods: create and mount.

The structure looks something like this:

Image by André Bittencourt

We created a GatewayController responsible for parsing the URL and deciding who will render that route (Angular or Vue), and also a gatewayTemplate.html with only an entry point to mount the Vue application:

<div id="gateway"></div>

In order to tell ngRoute to call our GatewayController when it matches some prefixes, there’s a very weird trick:

Changing ngRoute regex manually

This is why ngRoute only accepts a fix path (like /gateway), but we want to call gateway controller and template for all our microfrontend prefixes. So, in order to not repeat ourselves defining many almost identical routes, we prefer override a generated route to use the good old regex.

Ok, so now we’ve already told the ngRoute to call the gateway controller/template. But what it really does? It calls the right microfrontend using Asynchronous Module Definition (AMD), that is, RequireJS. Each microfrontend will be a Javascript module asynchronously requested according to the URL prefix. For example, whenever the URL matches the prefix /sales, like app.contaazul.com/sales/edit, the Vue module sales.js will be requested and mounted.

gatewayController requiring the right microfrontend by URL prefix

The bundle sales.js have to export two methods: create, responsible for creating the Vue (in our case) instance, and mount, responsible for mounting the app on the entry point (gatewayTemplate.html).

Simple example of microfrontend mount and create methods using Vue

And that’s it. In our case, we maintain our application default “frame”, which is basically our Angular topbar and breadcrumb, and then mount the Vue applications in its core, which are the screens themselves.

Here are some benefits we can have by using this approach:

  • We can start using our brand new Vue components “inside” our main product (but not inside the code, which is good).
  • Teams can be even more autonomous and have end-to-end control of whole module they work with.
  • Working with new technologies increases engagement and encourages people to do their jobs always the better way it’s possible. Yes, I know we are not vandals, but here’s a thought:

Consider a building with a few broken windows. If the windows are not repaired, the tendency is for vandals to break a few more windows

  • Deploying microfrontends can be independent of deploying monolith, which improve our continuous delivery and makes us faster.
  • Technology agnosticism. Although we have chosen Vue it’s perfectly fine for you to chose any other framework, since it have create and mount methods (we haven’t tested the approach with other frameworks yet but in theory it should work).

Unfortunately, the holy grail of software architecture doesn’t exist, so we still have to handle some issues:

Performance

It’s important to map a strategy of sharing dependencies to avoid loading the same file several times. For example: if every microfrontend has Vue as dependency (probably the same version) and all of them are running in the same environment (the monolith), does it really make sense that each one requires the same bundle? Maybe we need to chunk some dependencies in order to require them only once.

Cache

When we talk about getting a microfrontent bundle (like sales.js) using RequireJS, it means that we need to declare its path. This path may point to a static server which will be updated in every deploy (for example, in a very simplistic way, https://localhost:8085/sales.js). In this case, when we deploy a new version of this microfrontend under the same filename (sales.js), the monolith will not be necessarily using the latest deployed version, but a cached one.

One of the possibles alternatives to solve this issue is to publish a service that stores the latest deployed version of each microfrontend, then the monolith asks for this information before requesting the right bundle version.

Another alternative could be to publish the microfrontends as versioned npm dependencies and import them into monolith. The downside is that we lose the independence of deploying microfrontends since we must update the monolith package.json every time we release a new version.

So, what do you think about working on legacy projects? Do you have any hopes or concerns? Have you ever solved a similar problem in a different way?

If you have any doubt, critic or suggestion, let us know!

Thank you for reading!

--

--