SPA doesn’t mean Single Page Architecture; Building Micro Frontends

David FanninArchitecture

More often than not, our micro service architecture stops at the API layer. We meticulously build our backend architecture to align with our problem domains, ensure we don’t have data contagion, and pat ourselves on the back for a job well done. In actuality, there is still a lot more work to be done. When it comes down to it, users care little to nothing about the backend API. Many do not even realize that it exists. They use and give feedback on frontend applications. When the app performs poorly, they don’t get frustrated about the poorly designed SQL query. Instead, they say the website is sluggish and slow. In this, we should follow our customers’ lead and make sure our frontend apps get the proper attention they deserve.

Micro frontends are frontend micro services. This concept is very simple in theory, but just like most things in life, applying that theory can prove very difficult. All Domain Drive Design (DDD) principles that you apply to your backend APIs should also be applied to your micro frontends. However, micro frontends also come with their own unique set of challenges.

Challenge 1: Mental Preparedness

  • “Domain Driven Design? Isn’t that a backend thing?”
  • “Building micro services on the frontend seems a little overkill, there’s a reason they’re called Single Page Applications…”
  • “At least we’re not using jQuery.” – My personal favorite

When it comes to micro frontends, you might think that there are only technical challenges to overcome, but the mental and emotional challenges are just as difficult, if not more so. Surprisingly, it’s still pretty common to hear engineers speak of frontend apps as second-class citizens, viewing them as “websites” instead of part of the core platform. It’s often viewed as outside of the realm for strict architecture and modeling patterns.

We must even retrain ourselves in how we reason about our code organization. As someone who has worked in frontend roles – for many years – I can tell you that this can be difficult. Just like the transition from loosely typed JavaScript to strictly typed TypeScript was difficult, the transition from the monolithic frontend to micro frontends with strict bounded contexts is not easy. Instead of viewing frontend apps as a hodgepodge of webpages thrown together to allow users a way to input and view data, we should see them as what they truly are: another harmonious piece to our software platform puzzle. Applying DDD to your backend architecture is great. However, until it is applied across the entire software platform, the effect it will have on your squad’s ability to align with the company will be minimal.

Challenge 2: Learning How to Share

When building micro frontends, learning when and how to share code is essential. Whether it’s utility classes or UI components, we all know that the “The Copy and Paste Pattern” just doesn’t work. At Realtracs, we follow a Rule of Three. This means, that if we’ve built the same thing at least 3 times, we should refactor it so that it can be shared.

However, just putting code in a shared library isn’t enough. We also need to ensure our shared code is generic. Customizing shared code for a specific micro service should immediately throw up a red flag. The micro service is responsible for modifying the input and output to meet the requirements of the shared code, not the other way around. Shared code should not be domain specific. In fact, the necessary models and interfaces should be defined in the library along with the shared code. Since shared code is not domain specific, all developers members should be allowed to modify it regardless of squad. Ensuring your shared code is backed by good unit tests is vital so that different squads can confidently make changes without fear of breaking a micro service managed by a different squad.

Challenge 3: Being Indistinguishably Different

A consistent experience across each of the micro services is just as important as code duplication. Users should not be able to tell when they have navigated from one micro service to another. This means that micro service X should not have modals with rounded edges, while micro service Y has modals with square edges. Styles and UI can diverge quickly when building new micro services, so you must be intentional and proactive to tackle it before it gets out of control. We use the combination of an internal style guide and styling framework that ensures all of our UI components, colors, sizes, etc. are consistent across all of our micro services.

Challenge 4: UX Has No Bounded Contexts

Learning how to solve for our unbounded UX was possibly the most difficult challenge of all. Our first frontend micro service was simple. Since we only had a single app we did not need to share domain specific UI components. As we built more frontend micro services things quickly changed because in the real world our front-end apps aren’t meant to be completely siloed…they’re meant to provide the best possible user experience. Since our users don’t care about our domain boundaries, our UX could not be constrained by the same rules as our code.

This becomes very apparent when thinking in terms of a dashboard. Let’s assume our fictitious dashboard has a panel that allows a user to search for people in their contact book and view their  details. At Realtracs, we refer to this type of domain specific shared component as a widget. Should the dashboard know what a “contact” is? Should it know how to search for a “contact”? The simple answer is no. We ran into this sort of problem very quickly at Realtracs and iterated on our frontend architecture several times in order to solve it. Some changes were good and some were bad, but with each iteration we’ve learned valuable lessons.

Iteration 1: The Library

In our first attempt, we tried putting our widget into a shared NPM library. This worked well for a short period of time. We solved the problem of code duplication and our app had to know very little about the widget. However, this solution was FAR from perfect. The first thing you might have realized is that this solution violated our bounded context. We had both an app and a separate library with overlapping bounded context. Additionally, this pattern doesn’t work at scale. We inevitably had widget libraries that were dependent on another library and – even worse – on other widget libraries. What we ended up with was a tangle of library dependencies that was nearly impossible to unravel. This dependency spaghetti happens slowly over time and before you know it you have a huge mess on your hands. A change as small as fixing a typo in a shared component could have us backed up a week trying to track down and update all of the libraries and apps as well as regression testing all of the affected areas. We quickly realized this pattern did not work for us and went back to the drawing board.

The Pros

  • Less code duplication
  • Our app had to know very little about the widget

The Cons

  • Bounded context was violated
  • Dependency spaghetti
  • Impossible to scale

Iteration 2: The iFrame

If you cringe at the word iFrame, you’re not alone. Some of our widgets were used in legacy apps, so we could not rely solely on shared Angular libraries. We injected these special widgets inside of iFrames. This strategy worked fairly well for simple scenarios . Although our bounded contexts were now clean and we no longer had dependency spaghetti there were still many limitations, including: the slow iFrame load time and excessive use of memory, numerous mouse/touch/focus/scroll issues, e.g. double scroll bars, and the inability to scroll the iFrame contents without knowing the actual dimensions of the iFrame content. The list goes on and on.

The Pros

  • Our bounded contexts were clean
  • No dependency spaghetti

The Cons

  • Memory hog
  • Slow performance
  • Mobile touch event issues
  • Scrolling issues
  • Double scrollbar issues
  • Unable to use modals or full-page overlays
  • Unable to use certain popular frontend libraries

Iteration 3: The Angular Element

As we were hitting our breaking point with the frustrations of our iFrame widgets, the Angular team announced a cool new feature called Angular elements – Angular elements allow angular components to be wrapped in a custom HTML element, enabling us to leverage these widgets in our angular apps and in our legacy apps. This solved or improved all of the issues we had with the iFrame strategy. Using Angular elements was a huge step in the right direction, but there was still one thing we wanted to solve for: an angular element must be bootstrapped inside of an app. This means that when we loaded our widget we were loading the entire app as well.

The Pros

  • Our bounded context were clean
  • No dependency spaghetti

The Cons

  • Widget sources can be large

Iteration 4: The Micro MonoRepo

Since we didn’t want to load our entire app when all we wanted was a small portion, we decided to build a Micro MonoRepo using NRWL – Most use cases of MonoRepos are explained as a single repo that houses all of your apps in a single repository. The added benefit of the MonoRepo comes from intelligent detection of what apps or libraries have been affected and need to be rebuilt or tested. Since we are very intentional about applying strict DDD principles to our frontend this wasn’t exactly what we wanted, but the concept was intriguing. This was how we came to build our first Micro MonoRepo. Our Micro MonoRepos do not extend beyond a single bounded context. However, we have multiple apps inside of the repo. One for our core app and others for each of our Angular elements. This way our widgets do not load unnecessary code.

We have successfully been using Micro frontends for some time now. Once, it would have felt like a huge burden to make even a small change to one of our widgets. Now, it’s simple, quick, and extremely low risk. Even more importantly, we have a consistent architecture across our entire tech stack. Our backend domain aligns with our frontend domain which aligns with our team organization which in turn aligns with our business as a whole. Building an app is easy, but building a uniform platform that is scalable and maintainable is exponentially more difficult. It cannot be done overnight, but it can be done.