Shaping an Application with Packages

Using packages to implement a “micro frontend” architecture

--

Photo by Aleks Dahlberg on Unsplash

My previous article “Modularizing Applications with Ease” showed how to use packages to modularize your application. This article explains how we use packages to implement a “micro frontend” architecture for web applications.

Before we start I’d like to remind you of the old days, in which some of us were used to building applications in the classic n-Tier architecture.

Even when we separate code in different layers, sooner or later it may end up in a rather monolithic application. This presents various challenges to engineers, including:

  • refactoring, replacing or removing parts of the application
  • higher rate of merge conflicts and/or bugs
    — especially when you work on a complex application with multiple teams
  • hard to know who owns each part
  • tough to keep track of internal and external dependencies

In the backend world, we address these issues by introducing microservices, which encourages us to create dedicated services for each specific use case, and leads to a more vertical sliced architecture.

The good news is that we can also apply these same learnings to the frontend world, with a technique called “micro frontends,” which allows us to break up frontend monoliths into many smaller, more manageable pieces.

The following image shows an application sliced into complex use cases. Each use case is isolated from the others and can live on its own without strong dependencies on the other cases.

But sometimes complex use cases like these can be big enough on their own. If that happens, you may need to consider slicing them internally into more manageable pieces.

Terminology

Which brings us to the idea of using packages to shape our application. Before we begin, I’d like to introduce you to the terminology we use at eBay.

Apps

An app is what we used to call a “complex use case.” An app can run by itself without a strong need for another app. So it should not have any direct dependencies on other apps. An app may be made of multiple packages, but has at least one main package which contains the main or core functionality. It only exposes the necessary things to integrate into the shell.

Shell

The shell, or “host environment” provides the context in which the application(s) are hosted. It also provides one or more base layouts, a routing mechanism and maybe some components like the main navigation to switch between apps.

Features

Features are a way to enrich the core functionality of an app and are usually owned by one team. When you want to add something, you usually have at least two things to clarify:

  1. How to integrate the new feature into the core functionality?
  2. What are the minimal touchpoints?
    (where to integrate, what goes in, what events are emitted by the feature)

Smaller touchpoints between core functionality and features provide several advantages:

  • core functionality and feature code are easier to refactor when they are loosely coupled
  • independent features can be more easily replaced with others
  • feature toggles can be used to enable features based on configuration or user preferences
  • clear code ownership for each feature
  • better overview of dependencies within each feature and towards the core functionality

Contracts

Contracts are used when you want to share information between apps, features or the shell. They may include static information like routes, shared configuration or data that is available at runtime. Contracts are very helpful to decouple any direct dependencies between the elements of your architecture. In order to be most effective, contracts should not contain any presentational elements (e.g. HTML or CSS).

Sample application

To clarify the terms above, I’d like to give an example of one of the applications we’re working on.

This application is used by professional sellers to manage, analyze, and optimize their inventory, with features that include the following:

  • show an overview of the inventory (filter, search, …)
  • maintain inventory items (add, edit,…)
  • book additional options to enhance the visibility of an item
  • display reports about performance, and more
  • account administration (add and remove users from the dealership)

We use the concepts outlined above to structure the application, beginning with the shell.

The shell provides a layout where we have a sidebar on the left and a content area on the right. The side navigation allows us to switch between apps. These apps each use the main content area for their own integration.

We treat the following functionalities as apps:

  • Inventory
  • MaintainInventoryItems (internally called Sell Your Item)
  • Reporting
  • Administration

As described earlier, apps integrate themselves into the shell. In the example of the Inventory app, we have an integration with the navigation. When the user clicks the navigation item of the Inventory app, the inventory is loaded in the main content area.

This same pattern is used for most apps, but exactly where an app integrates into the shell is flexible — so we can place the button for the Administration app somewhere else. You might also decide you don’t want to integrate into the main content area, and use another placeholder area from the shell instead — or just show the content in a modal.

Booking additional options to enhance the visibility of a listing item would be an example of a feature. Let’s call it the promotion feature. The promotion feature will be used in the Inventory app and provides a list of buttons that enable different types of promotions. These buttons may appear on the top of the inventory list. The integration also provides a checkbox in each listing item rendered in the inventory list. The user can then select specific listing items and choose a promotion type for the inventory items by pressing a button.

Contracts, on the other hand, will be used for the integration within the navigation of the shell. For that, we define that every app that wants to integrate into the navigation has to define a navigation contract. That specific navigation has to implement a predefined interface, which exposes information like:

  • NavigationEntryText
  • NavigationIcon
  • NavigationRouterPath
  • SubnavigationItems

But also our global configuration is a contract — or we have a contract which provides us the current dealership (important information for all apps) the user is working on.

The following diagram shows how these different layers of vertical slices work together, and includes an example of a shared feature (Feature Y), which can be used in multiple apps.

We applied the same architecture to our codebase. The following tree shows applications in the apps folder, the shell has its own folder on the root level and we have a shared folder on the root level or inside the scope of each app.

Conclusion

With the help of packages, a minimal set of rules and the concepts above, we can easily split our codebase into isolated vertical slices. That helps us to work with multiple teams in parallel on one complex application. Limiting the touchpoints between apps and features lessens the pain of constant merge conflicts, so that teams can concentrate more on building or refactoring features. It also makes it easy to use feature flags when we work on a new feature or app and split that work into multiple smaller steps. When all steps are finished, we just enable the flag to roll the feature out to the user. Another nice side effect is that this makes code-splitting in combination with lazy loading a lot easier — but more on that (in combination with react) in my next article on Optimizing multi-package apps with TypeScript Project References, which includes a sample repository:

I’d like to thank all the teams that help to keep our codebase in good shape. Special thanks to René Viering, who supported me in the process of defining and applying this architecture and to Maximilian Eckhardt for providing these awesome illustrations.

--

--