Home Blogs Micro-frontend in smallcase
Engineering

Micro-frontend in smallcase

Micro-frontend in smallcase
Reading Time: 17 minutes

Micro-frontend is the technical representation of a business sub domain – they allow independent implementations with same or different technology choices. Finally, they should avoid sharing logic with other subdomains and they are own by a single team.
Luca Mezzalira

At smallcase, in Publisher, subscription flow use case fits this definition. This flow is being used by various creators (also knows as publishers) of private smallcases. It doesn’t necessarily mean we have a micro frontend architecture; however, we do leverage it to an extent.

Evolution of Subscription Flow in Publisher

Subscription flow allows retail investors to subscribe to private smallcases by Publishers. Last year (2019), subscription flow was only present in microsites (SPA provided by smallcase to publishers to showcase their investment research. For example, weekend investing, capitalmind, wright research …) as a react component. 

Subscription Flow in 2019
Subscription Flow in 2019
  • Before Oct 2019, Subscription flow was highly coupled with the microsite. So any changes in subscription flow required deployment and release for the publisher who has private smallcases on their microsite. This was very time-consuming as there was more than 30+ microsite to be shipped out and maintained. And the number of microsites was increasing month by month.
  • Between Nov 2019 to Dec 2019, big features were added on monthly basis like AuM based Plans, Risk Profiling, and support for promo code. The flow was becoming complex day by day. And State management was becoming messy and posing issues. This was due to the fact that microsite didn’t have any global state management solution like redux or content api.
  • In Jan 2020, Due to new SEBI regulation in Dec 2019, all new subscriptions had to be stopped. And because of this, we had to introduce a new payment method, and there were some changes in risk profiling as well.

This caused a major set back for the Publisher Team. And everyone was trying their level best to adapt and find potential solutions to solve this issue.

We wanted to solve many of the above problems, but we also had the vision to integrate subscription flow with other platforms and native app at smallcase with ease. New features to be developed in an isolated environment. We needed to have much easier and faster feature development, for that most the important thing was independent deployment with easy maintenance.

Now keeping all this in mind, we decided to take subscription flow out of the microsite and create a standalone application which later on took micro-frontend architecture and was called Subscription Widget (SW). It solved many of our problems if not all.

Embracing micro-world in Frontend

The micro-frontend approach feels a bit off for almost all front-end developers. The terms micro-frontend means that you break the frontend monolith into smaller, more manageable, and focused to be more autonomous. On the backend, such autonomous Microservices architecture is very popular.

So the question arises why we needed such architecture. There are a few reasons.

  • Independent Deployment. If we make a change to any one service, one service should be deployed.
  • Less coupled systems. Each service runs on its own, persists on its own and does one kind of operation mostly.
  • Framework or technologically agnostic. We can but we don’t have too. But it’s a very good thing that we can build services in framework and technology that fits the problem.
  • Smaller interface services. This means that the amount of API we expose from these services are limited.
  • Designed around a business domain.
  • Parallel development.
Monolith vs Microservice
Monolith vs Microservices

As an organisation grows over time, traditional monolithic design both on frontend and backend tends to amplify and enable coupling. This could lead to below problems:

  • Communication between teams/people becomes expensive.
  • Build time increases as more and more features are built.
  • Deployment of the Whole Application if there are any breaking changes in API.
  • Not able to use Modern tools and frameworks easily when dealing with legacy systems.

These kinds of problems can be solved using microarchitectures to an extent, but it does take a different mindset for the product team to implement and execute it.

Any refactoring of functionality between services is much harder than it is in a monolith. But even experienced architects working in familiar domains have great difficulty getting boundaries right at the beginning. By building a monolith first, you can figure out what the right boundaries are, before a microservices design brushes a layer of treacle over them.
Martin Fowler

I’m not saying that monoliths are poor design choice or building microservices or micro-frontend will improve or build a better application. They are just needed in a certain context. 

Orchestration of new Subscription Flow

There are many different ways to apply micro-frontend architecture to a new or existing project. The approach generally depends on the operational context.

For the new subscription flow, we use two approaches which are as follow:

Client-Side Composition

Initially, runtime composition was chosen to integrate the new flow into the microsite. The widget is built as an independent script & exposes a global class object as its entry point.

import React from 'react';
import ReactDOM from 'react-dom';

// small redux implementation to communicate outside
import store, { ACTIONS } from './utils/store';

/**
 * @class Subscription Widget.
 */
export default class SubscriptionWidget {
    static el;

    static version = `ℹ️ Subscription Widget version: ${VERSION}`;

    /**
     * It is used for mounting the widget in DOM of comsumer project.
     *
     * @param {object} payload - Information used by widget for mounting.
     * @param {string} payload.parentElement - Selector in which the widget will be mounted.
     * @param {object} payload.bridgeMethods - Bridge methods to which consumer to use to subscribe to subscription widget events.
     * @param {string} payload.consumer - Subscription Widget Consumer.
     * @param {boolean} payload.modal - Open Subscription Widget in modal.
     */
    static mount({
        parentElement = null,
        nativeAgent,
        consumer,
        modal,
        bridgeMethods
    } = {}) {
        /**
         * It renders the subscription widget along with it styles.
         *
         * @param {Function} resolve - To resolve the promise when app is mounted.
         */
        async function render(resolve) {
            if (SubscriptionWidget.el) {
                console.error(
                    'Subscription Widget is already mounted, unmounting it first'
                );
                SubscriptionWidget.unmount();
            }
            const el = document.createElement('div');

            el.setAttribute(
                'class',
                `${SUBSCRIPTION_WIDGET_SCOPE_CLASS}subscription-private-sc`
            );

            // It parentElement is not given as parameter, then a subscription flow div is appended
            // at the end of body with position absolute
            if (parentElement) {
                document.querySelector(parentElement).appendChild(el);
            } else {
                el.style.position = 'absolute';
                document.body.appendChild(el);
            }

            const { default: App } = await import(
                /* webpackChunkName: "App" */ './App/App.js'
            );

            // Component to be mounted by subscription widget
            const component = (
                <App
                    unMountWidget={SubscriptionWidget.unmount}
                    appMounted={resolve}
                    nativeAgent={nativeAgent}
                    consumer={consumer}
                    bridgeMethods={bridgeMethods}
                    modal={modal}
                />
            );
            ReactDOM.render(component, el);
            // store the DOM element on class so that it can later be unmounted
            SubscriptionWidget.el = el;
        }

        return new Promise((resolve, reject) => {
            if (document.readyState === 'complete') {
                render(resolve, reject);
            } else {
                window.addEventListener('load', () => {
                    render(resolve, reject);
                });
            }
        });
    }

    /**
     * It unmounts the subscription widget from the DOM.
     */
    static unmount() {
        if (!SubscriptionWidget.el) {
            throw new Error('Subscription Widget is not mounted, mount first');
        }
        ReactDOM.unmountComponentAtNode(SubscriptionWidget.el);
        SubscriptionWidget.el.parentNode.removeChild(SubscriptionWidget.el);
        SubscriptionWidget.el = null;
    }

    /**
     * It opens the subscription widget modal with the given payload.
     *
     * @param {{scid: string,
     * publisher: string,
     * smallcaseUserId: string,
     * distributor: string,
     * attributionDetails: object }} payload - Object passed to the subscription widget.
     */
    static open(payload) {
        store.dispatch({
            type: ACTIONS.OPEN_MODAL,
            payload
        });
    }

    /**
     * It closes the subscription widget modal.
     */
    static close() {
        store.dispatch({
            type: ACTIONS.CLOSE_MODAL
        });
    }
}

The host application (microsite in this case) has the logic to load required modules on demand & mount into the required DOM node. Once the microsite is loaded, the widget endpoint is queried in the background to fetch the latest widget js file.

<script> // this can also be included in componentDidMount life cycle of the main app.
    const s = document.createElement('script');
    s.type = 'text/javascript';
    s.src = `${SUBSCRIPTION_WIDGET_ENV_END_POINT}`;
    s.async = true;
    document.head.append(s);
    s.onload = () => {
        // Mount widget once comsumer project is loaded
        window.SubscriptionWidget.mount({
            parentElement: 'selector'
        });
    };
</script>

The file is then parsed and executed. After the complete load, SubscriptionWidget object is attached on the window. This object is used to mount subscription flow into the DOM at the specified position. The widget can be opened easily anywhere on the microsite.

window.SubscriptionWidget.open({
    publisher: 'weekend-investing', // publisher name
    scid: 'WKIMO_0004', // smallcase id
    token: 'XXXXXXXX.XXXXXXXXXX.XXXXXXXXX',
    connectToken: 'XXXXXX.XXXXXXXXXXXX.XXXXXXXXX'
    consumer: 'microsite' // microsite or smallcase app
});

One of the benefits of the above approach is that only the widget scripts need to be updated for changes to reflect without updating microsite. This leads to atomic deployment. The same approach is also used to extend subscription flow support of inside native smallcase app.

Client Side Composition used on Microsites and smallcase Native App
Client-Side Composition used on Microsites and smallcase Native App

Run time composition saves us from deploying 50+ SPA microsites . 🎉

Over time we felt this orchestration has few downsides also. These are as follows:

  • Too many network call from the client, which can be solved using caching mechanisms to an extent.
  • The bundle size increase due to vendor dependencies not being shared which could have if it was a monolith. But there are other ways through which once can share dependencies, but it might require a restructuring of how we maintain frontend dependencies across projects in general.

Built Time Composition

For integration with smallcase platform across various brokers, the above composition method is not used. This kind of micro-app was not recommended to be independent of the Broker’s platform deployment. The majority of subscription flow testing happens on the microsite by Publisher Team. In order to ensure that nothing breaks on the broker platform and to have that coupling, this composition was adopted.

The underlying ecosystem of both widget and platform is React. Hence a simple micro-frontend as a react component packaged is published (as npm package). This is used and bundled together with the broker’s platforms final build.

import React from 'react';
import { useSelector, shallowEqual } from 'react-redux';

import SubscriptionWidget from '@smallcase/subscription-widget';

function Subscription(props) {
  const smallcaseUserId = useSelector(state => state.userData.accountDetails._id, shallowEqual);
  return (
      <SubscriptionWidget
        env={ENV}
        scid={props.smallcaseData.scid}
        modal={false}
        open={true}
        consumer="broker-platform"
        distributor={BROKER}
        publisher={props.smallcaseData.info.creator}
        smallcase={props.smallcaseData}
        smallcaseUserId={smallcaseUserId}
        bridgeMethods={{
          onMessage: (data) => {
            console.log(data);
          },
        }}
      />
  );
}

export default Subscription;

This approach enjoys the best of both worlds: safety and robustness of traditional monoliths and simplicity and scalability of micro-frontend. But in the end, it will always be a monolithic build by two different teams.

This kind of approach has few drawbacks such as:

  • It is not technology/stack agnostic which we can let go as both broker platform and widget uses React with compatible version at its core.
  • It violates the independent deployment requirement. This is something we can manage as the widget won’t be used outside the smallcase ecosystem directly.
  • There will be non-atomic deployments. First, the new Subscription Widget package with the latest changes has to be published and then the broker platform team has to update the dependency. The new version of subscription flow can be made live as soon as Broker platform ships out a new version of their platform across various brokers.
  • Dynamic loading of code split npm package is not supported by Webpack 4. For this, we had to use webpack’s CopyPlugin to copy rest of the package chunk into the final platform’s build.
Broker Platform's Subscription Widget Integration
Broker Platform’s Subscription Widget Integration

But on the bright side, we get a major performance boost due to good dependency management, resulting in smaller bundles. And we still have independent development. We were able to use react and webpack ecosystem to it fullest such as code splitting, error handling, props passing etc. 

With webpack 5, we can use its module federation feature to help build time composition integration much easier.

In the near future, I would prefer to deprecate this composition (and use client-side composition) once we have a better testing strategy so that subscription flow can be tested irrespective of its host application.

The next step

Once the approaches were clear, we had more integral problems to solve such as sharing of data between application, UI consistency, Assets Loading, Local Development environment etc. Let see how we solved a few of these problems.

How to create a seamless and consistent widget UI experience when we have a totally independent standalone micro-app?

Button Component in Falcon Design System

There is no perfect answer to this. If you already have a style guide or design system in place, using it will make sure the UI looks consistent. Luckily for me at smallcase, we have an in-house design system framework called Falcon Design System. The widget uses global style for typography, buttons, inputs, spacers, images etc from the framework so that the UI looks consistent across the host applications.

How do we make sure that the widget is not overriding the CSS written by another team?

One of the solutions is CSS sandboxing/scoping. For Component localized CSS, we use CSS modules.

And for global CSS, I wrote a babel and post-CSS plugin that traverse all CSS and js file to scope those classes to the widget. This is done by adding some prefix to the global selector to make the class name unique.

How should we share the global information between the widget and the host application?

For host application to send data to the subscription widget, there are two ways.

  • In Client-side composition, we use a simple global state handler which is a miniature implementation of redux, this way we achieve more reactive architecture.
  • In Build-time composition, prop passing to the internals of subscription widget.
/**
 * It redirects the users to repective platform after the
 * user click Invest now button.
 *
 * @param {object} subscriptionRootState - Subscription RootState
 */
export function redirection(subscriptionRootState) {
  const { nativeAgent, userSelectedSCScid } = subscriptionRootState;
	....
	....
  // send message back to consumer once the user is subscribed
  bridgePubSub.publish(BRIDGE_EVENTS.MESSAGE, {
    msg: "user successfully subscribed",
    code: "S0",
  });
}

Pubsub mechanism is used to send data outside to the host application.

How to serve the widget for native application?

A wrapper (shown below) around widget is used by Native smallcase app for Android and IOS. Native App opens the Widget inside a web view (Android) or a chrome tab (IOS). The App uses ScriptMessageHandler (IOS), addJavascriptInterface (Android) to handle communication between the Native App and web view. The App creates the delegate methods to receive messages in its view controller.

import queryString from 'query-string';
// get subscription widget input params from query parameter
const data = queryString.parse(window.location.search);

const nativeMethods = {
  onMessage: 'onMessage',
};

function nativeBridgeMethodWrapper(fnName, data) {
  window?.webkit?.messageHandlers?.["onMessage"]?.postMessage((JSON.stringify(data)));
  window?.android?.["onMessage"]?.(JSON.stringify(data));
}

const bridgeMethods = {
  onMessage: (data) => nativeBridgeMethodWrapper(nativeMethods.onMessage, data),
};

function mountAndOpenWidget() {
    window.SubscriptionWidget.mount({..., bridgeMethods}).then(() => {
        window.SubscriptionWidget.open({...data});
    });
}

function init() {
    injectSubscriptionScript().then(mountAndOpenWidget);
}

init()

Above code-snippets open the subscription flow automatically in webview or chrome tab. It also adds callbacks which call the message handler that is set up by WKWebView and Webview. When the native bridge method is called by widget, the native app receives the JavaScript object.

How to serve the latest version of the widget?

The concept is pretty straightforward. If you want to integrate a micro frontend from another team, you have to add their references to make it work.

For Client-side Composition, Direct Reference (using a <script> tag) is used. The initial chunk of the widget is not a fingerprinted filename and is served as SW.js. This is done so that the host applications would not have to manually update these references in their markup every time Publisher Team deploys a new version. To ensure host application always get the latest flow, a proper caching strategy is followed by using appropriate cache-control headers:

Client Side Composition Build Process
Client-Side Composition Build Process
  • Most of the widget’s artifacts are fingerprinted, whenever we change something and do a build the hash in the filename will change if there is any change. So we apply public, max-age=864000, immutable cache-control headers to all fingerprinted files.
  • We want to make sure that the browser is never using SW.js from its cache without revalidation, so we set the cache-control headers as no-cache
Request Logs for Subscription Widget Artifacts
Request Logs for Subscription Widget Artifacts

Whenever a new version is released, the browser fetches the latest main chunk from CloudFront ( assets invalidation is done by that time ). The main chunk then fetches the rest of the newly hashed artifacts. There is only one drawback to this. The browser can’t cache the initial resource of subscription flow. It has to make at least one network request to make sure the micro-app is up to date. So we make sure that this file size is as low as possible.

For build time composition, the widget is published as a semantically versioned NPM package. Updating the version number in package.json of the host application will server the latest flow.

How to do local development for the widget?

First thing, don’t run other team’s code. and also having to know about the development environment of other teams introduces friction. For development purpose, we use Storybook which provides separate GUI to browse and plays with component and various possible screen of the widget.

Storybook Setup for Subscription Widget
Storybook Setup for Subscription Widget

By using Storybook, it allowed everyone one, including designers, business team, and product manager to track and easily check building block available without having to interact with the host applications.

How to handle error logging inside the widget?

At smallcase, we use sentry for error tracking so we can ensure our users always have great experience into managing their personal finance using smallcases, and also for monitoring and alerting mostly, so that we, developers, can sleep at night without worry.

Sentry Dashboard for Subscription widget
Sentry Dashboard for Subscription widget

Integrating third-party services like sentry into such micro-frontend architecture can be tricky. The host project may or may not have sentry integrated into their platform. So for this reason widget manages its own Sentry Client which prevent tracking of any host project errors into widget’s sentry project.

/**
 * Initialize sentry.
 */
function sentryInit() {
    const client = new Sentry.BrowserClient({
        dsn: SENTRY_DSN_URL,
        integrations: Sentry.defaultIntegrations,
        release: VERSION,
        environment: ENV,
        normalizeDepth: 10,
        beforeSend(event) {
            if (DISTRIBUTION === 'package') {
                /**
                 * as package environment is set at runtime for package
                 * distribution, so environment to the
                 * package is also send at runtime
                 */
                const { ENV: dynamicENV } = store.getState();
                event.environment = dynamicENV || ENV;
            }
            return event;
        }
    });
    const hub = new Sentry.Hub(client);

    hub.configureScope(function(scope) {
        scope.setTag('distribution', DISTRIBUTION);
    });
    setSentryForWidget(client, hub);
}

Tradeoffs

Micro-frontend is powerful but doesn’t come for free. Few of the tradeoff of this approach is Code Redundancy, Heterogeneity increases. etc as seen in above orchestrations.

Some tradeoffs were very specific to smallcase development ecosystem.

Avoided the use of Iframe based solution

A very popular approach to micro-frontend is the use of Iframe, which creates an isolated environment so that host project can continue functioning without any worries.

Iframe solution is embedded into smallcase ecosystem for Brokers like Axis and HDFC. Our payment gateway integrations for subscription flow includes Razorpay and Digio which are also an iframe-based solution. This would have let to Iframe within Iframe hell. Keeping in mind the future of widget which will be exposed to others outside the smallcase ecosystem using smallcase gateway which itself is an iframe-based solution. This would have made the subscription flow module much more complex and difficult to develop and debug due to the use of post messages and other use-cases.

Avoided the use of Micro-fronted framework

The widget uses react at its core. Major Business logic was already written in react when subscription flow was tightly coupled with the microsite. So we reused many components, services and utilities for the widget as well. We could have used other frameworks like Svelte, Elm or even vanilla which have less JS footprint. But due to time constraint of development and maintaining later in the new framework; we decided to use the existing logic and life cycles. And also currently subscription flow for private smallcase fits the micro-frontend definition and so far we have not come across another use case at smallcase that might need such implementation. Hence we didn’t use frameworks like Single-spa, Luigi, qiankun etc, which would have added more overhead to restructure multiple host application.

New Screens for Subscription Flow
New Screens for Subscription Flow

Thanks for your patience if you have made this far. In the end, I would say that this approach helped us to align the business with the tech side, unifying de facto 2 main areas of the publisher team: product and tech in the context of subscription flow.

You may want to read

Your email address will not be published. Required fields are marked *

Micro-frontend in smallcase
Share:
Share via Whatsapp