Micro Frontend with Single-SPA

In order to build applications which utilise the scalability, flexibility, and resilience of cloud computing, the applications are nowadays normally developed with microservice architecture using containers. Microservice architecture enables our applications to be composed of small independent backend services that communicate with each other over the network.

Project GitHub Repository

The complete source code of this project can be found at https://github.com/goh-chunlin/Lunar.MicroFrontEnd.SingleSpa.

Why Micro Frontend?

In general, when applying a microservice architecture, while backend systems are split up into microservices, frontend is still often developed as a monolith. This is not a problem when our application is small and we have a strong frontend team working on its frontend. However when the application grows to a larger scale, a monolithic frontend will start to be inefficient and unmaintainable due to the following reasons.

Firstly, it is challenging to keep the frontend technologies used in a large application up-to-date. Hence, with micro frontend, we can upgrade the version of the frontend on a functional basis. It also allows developers to use different frontend technologies to different functions based on the needs.

Secondly, since the source code of the micro frontend is separated, the source code of the individual frontend component is not as much as the monolith version of it. This improves the maintainability of the frontend because smaller code is easy to understand and distribute.

Thirdly, with micro frontend, we can split the frontend development team into smaller teams so that each team only needs to focus on relevant business functions.

Introduction of single-spa

In micro frontend architecture, we need a framework to bring together muliple JavaScript micro frotnends in our application. The framework we’re going to discuss here is called the single-spa.

The reason why we choose single-spa is because it is a framework allowing the implementation of micro frontend by supporting many popular JavaScript UI frameworks such as Angular and Vue. By leveraging the single-spa framework, we are able to register micro frontends such that the micro frontends are mounted and unmounted correctly for different URLs.

In single-spa, each micro frontend needs to implement their lifecycle functions by defining the actual implementation for how to bootstrap/mount/unmount components to the DOM tree with JavaScript or a different flavour of the JavaScript framework.

In this article, single-spa will work as an orchestrator to handle the micro frontend switch so that individual micro frontend does not need to worry about the global routing.

The Orchestrator

The orchestrator is nothing but a project holding single-spa which is responsible for global routing, i.e. determining which micro frontends get loaded.

We will be loading different micro frontends into the two placeholders which consume the same custom styles.

Fortunately, there is a very convenient way for us to get started quickly, i.e. using the create-single-spa, a utility for generating starter code. This guide will cover creating the root-config and our first single-spa application.

We can install the create-single-spa tool globally with the following command.

npm install --global create-single-spa

Once it is installed, we will create our project folder containing another empty called “orchestrator”, as shown in the following screenshot.

We have now initialised our project.

We will now create the single-spa root config, which is the core of our orchestrator, with the following command.

create-single-spa

Then we will need to answer a few questions, as shown in the screenshots below in order to generate our orchestrator.

We’re generating orchestrator using the single-spa root config type.

That’s all for now for our orchestrator. We will come back to it after we have created our micro frontends.

Micro Frontends

We will again use the create-single-spa to create the micro frontends. Instead of choosing root config as the type, this time we will choose to generate the parcel instead, as shown in the following screenshot.

We will be creating Vue 3.0 micro frontends.

To have our orchestrator import the micro frontends, the micro frontend app needs to be exposed as a System.register module, as shown below on how we edit the vue.config.js file with the following configuration.

const { defineConfig } = require('@vue/cli-service')
module.exports = defineConfig({
  transpileDependencies: true,
  configureWebpack: {
    output: {
      libraryTarget: "system",
      filename: "js/app.js"
    }
  }
})
Here we also force the generated output file name to be app.js for import convenience in the orchestrator.

Now, we can proceed to build this app with the following command so that the app.js file can be generated.

npm run build
The app.js file is generated after we run the build script that is defined in package.json file.

We then can serve this micro frontend app with http-server for local testing later. We will be running the following command in its dist directory to specify that we’re using port 8011 for the app1 micro frontend.

http-server . --port 8011 --cors
This is what we will be seeing if we navigate to the micro frontend app now.

Link Orchestrator with Micro Frontend AppS

Now, we can return to the index.ejs file to specify the URL of our micro frontend app as shown in the screenshot below.

Next, we need to define the place where we will display our micro frontend apps in the microfrontend-layout.js, as shown in the screenshot below.

<single-spa-router>
  <main>
    <route default>
      <div style="display: grid; column-gap: 50px; grid-template-columns: 30% auto; background-color: #2196F3; padding: 10px;">
        <div style="background-color: rgba(255, 255, 255, 0.8); padding: 20px;">
          <application name="@Lunar/app1"></application>
        </div>
        <div>

        </div>
      </div>
      
    </route>
  </main>
</single-spa-router>

We can now launch our orchestrator with the following command in the orchestrator directory.

npm start
Based on the package.json file, our orchestrator will be hosted at port 9000.

Now, if we repeat what we have done for app1 for another Vue 3.0 app called app2 (which we will deploy on port 8012), we can achieve something as follows.

Finally, to have the images shown properly, we simply need to update the Content-Security-Policy to be as follows.

<meta http-equiv="Content-Security-Policy" content="default-src 'self' https: localhost:*; img-src data:; script-src 'unsafe-inline' 'unsafe-eval' https: localhost:*; connect-src https: localhost:* ws://localhost:*; style-src 'unsafe-inline' https:; object-src 'none';">

Also, in order to make sure the orchestrator indeed loads two different micro frontends, we can edit the content of the two apps to look different, as shown below.

Design System

In a micro frontend architecture, every team builds its part of the frontend. With this drastic expansion of the frontend development work, there is a need for us to streamline the design work by having a complete set of frontend UI design standards.

In addition, in order to maintain the consistency of the look-and-feel of our application, it is important to make sure that all our relevant micro frontends are adopting the same design system which also enables developers to replicate designs quickly by utilising premade UI components.

Here in single-spa, we can host our CSS in one of the shared micro frontend app and then have it contains only the common CSS.

Both micro frontend apps are using the same design system Haneul (https://haneul-design.web.app/).

Closing

In 2016, Thoughtworks introduced the idea of micro frontend. Since then, the term micro frontend has been hyped.

However, micro frontend is not suitable for all projects, especially when the development team is small or when the project is just starting off. Micro frontend is only recommended when the backend is already on microservices and the team finds that scaling is getting more and more challenging. Hence, please plan carefully before migrating to micro frontend.

If you’d like to find out more about the single-spa framework that we are using in this article, please visit the following useful links.

RESTful Web Service in Golang and Front-end Served with VueJS

Continue from the previous topic…

There is one behaviour in our Golang web application that will frustrate our users. Everytime we add, update, or delete a video record, the web page gets refreshed. So if at that time there is a video being played, then poof, it’s gone after you add, update, or delete a record. That’s a bad UX.

To overcome there, I decide to implement RESTful web services in the Golang project and the frontend will use VueJS library to update the web page.

Firstly, we need to wrap a web service interface over the CRUD functions we have in our web application. JSON will be used as the data transport format. To do that, we will introduce a new handler function to multiplex request to the correct function in our RESTful web service.

func handleVideoAPIRequests(video models.IVideo) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {

var err error
switch request.Method {
case "GET":
err = handleVideoAPIGet(writer, request, video)

case "POST":
err = handleVideoAPIPost(writer, request, video)

case "PUT":
err = handleVideoAPIPut(writer, request, video)

case "DELETE":
err = handleVideoAPIDelete(writer, request, video)
}

if err != nil {
util.CheckError(err)

return
}

}
}

HTTP GET

Then for each of the HTTP methods, we will process the request independently. For example, to retrieve the list of videos or one of the videos, we will use GET method, i.e. handleVideoAPIGet().

The list of video records at the right are retrieved using HTTP GET.
func handleVideoAPIGet(writer http.ResponseWriter, request *http.Request, video models.IVideo) (err error) {

videoIDURL := path.Base(request.URL.Path)

var output []byte

if videoIDURL == "video" {
videos, errIf := video.GetAllVideos()
err = errIf
util.CheckError(errIf)

output, errIf = json.MarshalIndent(&videos, "", "\t")
err = errIf
util.CheckError(errIf)

writer.Header().Set("Content-Type", "application/json")
writer.Write(output)

return
}

videoID, err := strconv.Atoi(videoIDURL)

if err != nil {
util.CheckError(err)
return
}

err = video.GetVideo(videoID)
util.CheckError(err)

output, err = json.MarshalIndent(&video, "", "\t")
util.CheckError(err)

writer.Header().Set("Content-Type", "application/json")
writer.Write(output)

return
}

This method seems long but in the first half of the function, it is checking if the URL ends with /video or it ends with a number. If it ends with /video, that means it is not asking for a specific video but it’s asking for all valid videos. Thus video.GetAllVideos() is called and the videos are returned as JSON array.

So what if it’s only requesting for particular video with video ID? There is where the second half of the function comes in. It will first convert the part of the URL into an integer using strconv.Atoi(). Then we will retrieve the video based on that integer as video ID and then return it as a JSON object.

HTTP POST

To create a new video record in database, the system will call the handleVideoAPIPost().

func handleVideoAPIPost(writer http.ResponseWriter, request *http.Request, video models.IVideo) (err error) {

length := request.ContentLength
body := make([]byte, length)
request.Body.Read(body)
json.Unmarshal(body, &video)

err = video.CreateVideo()
if err != nil {
util.CheckError(err)
apiStatus := models.APIStatus{
Status: false,
Message: err.Error(),
}

output, err := json.MarshalIndent(&apiStatus, "", "\t")
util.CheckError(err)

writer.WriteHeader(400)
writer.Header().Set("Content-Type", "application/json")

writer.Write(output)
} else {
apiStatus := models.APIStatus{
Status: true,
Message: "A video is successfully added to the database.",
}

output, err := json.MarshalIndent(&apiStatus, "", "\t")
util.CheckError(err)

writer.WriteHeader(200)
writer.Header().Set("Content-Type", "application/json")
writer.Write(output)
}

return
}

The beginning of the function for POST method is reading from body is because we will post a JSON object containing the new video record information to it. So it needs to retrieve the JSON object from the body.

Another interesting thing in this function is that it will return JSON object indicating whether the action of adding new record is successful or not with a corresponding HTTP status code of 400 or 200. Doing so is to let the frontend feedback to the user so that the user knows whether the record is successfully inserted to the database or not.

HTTP PUT

How about if we want to update existing video record? Well, we can rely on the PUT method. The PUT method requires us to tell it which resource it will update. Hence the function handleVideoAPIPut() is as follows.

func handleVideoAPIPut(writer http.ResponseWriter, request *http.Request, video models.IVideo) (err error) {

videoIDURL := path.Base(request.URL.Path)
videoID, err := strconv.Atoi(videoIDURL)
if err != nil {
util.CheckError(err)
return
}

err = video.GetVideo(videoID)
if err != nil {
util.CheckError(err)
apiStatus := models.APIStatus{
Status: false,
Message: err.Error(),
}

output, err := json.MarshalIndent(&apiStatus, "", "\t")
util.CheckError(err)

writer.WriteHeader(400)
writer.Header().Set("Content-Type", "application/json")
writer.Write(output)
}

length := request.ContentLength
body := make([]byte, length)
request.Body.Read(body)
json.Unmarshal(body, &video)

err = video.UpdateVideo()
if err != nil {
util.CheckError(err)
apiStatus := models.APIStatus{
Status: false,
Message: err.Error(),
}

output, err := json.MarshalIndent(&apiStatus, "", "\t")
util.CheckError(err)

writer.WriteHeader(400)
writer.Header().Set("Content-Type", "application/json")
writer.Write(output)
} else {
apiStatus := models.APIStatus{
Status: true,
Message: "A video record is successfully updated.",
}

output, err := json.MarshalIndent(&apiStatus, "", "\t")
util.CheckError(err)

writer.WriteHeader(200)
writer.Header().Set("Content-Type", "application/json")
writer.Write(output)
}

return
}

Similar to how we have done in handleVideoAPIGet(), we first need to get the video ID from the URL with the help of strconv.Atoi(). Then we will check whether there is an existing video in the database with the video ID. If there is none, then we simply return JSON object updating frontend with an error message. If there is video found with the video ID, we will then proceed to update it with the info from the JSON object passed via the request body.

There is one thing to take note here is that we are not replacing the existing video record entirely. We are only updating part of it. So the JSON object should contain only the fields needed to be updated.

Updating the video record.

HTTP DELETE

The function to handle DELETE method will be similar to the one handling PUT method.

func handleVideoAPIDelete(writer http.ResponseWriter, request *http.Request, video models.IVideo) (err error) {

   videoIDURL := path.Base(request.URL.Path)
    videoID, err := strconv.Atoi(videoIDURL)

    if err != nil {
        util.CheckError(err)
        return
    }

   err = video.GetVideo(videoID)
   if err != nil {
        util.CheckError(err)

        apiStatus := models.APIStatus{
            Status: false,
            Message: err.Error(),
        }
        output, err := json.MarshalIndent(&apiStatus, "", "\t")
        util.CheckError(err)

        writer.WriteHeader(400)
        writer.Header().Set("Content-Type", "application/json")
        writer.Write(output)

    }

    err = video.DeleteVideo()
    if err != nil {

        util.CheckError(err)

        apiStatus := models.APIStatus{
            Status: false,
            Message: err.Error(),
        }
        output, err := json.MarshalIndent(&apiStatus, "", "\t")
        util.CheckError(err)

        writer.WriteHeader(400)
        writer.Header().Set("Content-Type", "application/json")
        writer.Write(output)

    } else {

        apiStatus := models.APIStatus{
            Status: true,
            Message: "A video record is deleted.",
        }
        output, err := json.MarshalIndent(&apiStatus, "", "\t")
        util.CheckError(err)

        writer.WriteHeader(200)
        writer.Header().Set("Content-Type", "application/json")
        writer.Write(output)

    }

    return
}

Similar to how we have done in handleVideoAPIPut(), we first need to get the video ID from the URL with the help of strconv.Atoi(). Then we will check whether there is an existing video in the database with the video ID. If there is none, then we simply return JSON object updating frontend with an error message. If there is video found with the video ID, we will then proceed to delete it.

The popup to check if user really wants the video record to be removed from the list.

Frontend with VueJS

Now, let’s see how we use VueJS library to display the video list.

It is done with just a for loop to list down all the relevant videos and having values stored in data attributes for update and delete the video record.

References