By Leo Horie
A little-known fact is that Uber builds a lot of web-based applications, hundreds of them and counting, in fact. Many of them are internal apps for managing various aspects of the business while others are public facing.
A more well-known fact is that web technologies change quickly and best practices are constantly evolving. Providing a high quality framework with modern features to hundreds of web engineers while keeping up with the dynamic nature of the web platform has historically been a challenge.
To address this challenge, Uber’s Web Platform team built Fusion.js, an open source web framework that makes web development easier and produces lightweight, high-performing apps.
As web industry best practices evolved, Uber needed to revamp its aging monolithic web framework to something that addressed the challenges posed by the years-long accrual of technical debt. However, we also wanted to let engineers keep using the technologies they love (e.g., React, and Redux) while maintaining compatibility with Uber’s app health monitoring infrastructure.
Specifically, we wanted the core framework to address the following pain points:
- Complex configuration and required boilerplate of multiple tools needed for server-side rendering, code splitting, and hot module reloading
- Lack of good abstractions for implementing and sharing features that involve different aspects of server-rendered React applications (i.e., spanning both server and client, dealing with serialization/hydration, server-client communication, etc.)
- Brittleness resulting from tight coupling of code located in different places
- Testing difficulties arising from side effects and singletons
- Lack of flexibility of a monolithic framework
While existing solutions addressed some of these challenges, we found that gluing a library on top of a framework often required changes to multiple unrelated files. For example, supporting Redux in a server-renderable app involves adding setup code somewhere in the server-related files, similar code somewhere in the browser ones, hydration code to the HTML template, a React Provider component, etc. Integrating an i18n library or browser performance metrics library leads to the same problem.
To make matters more difficult, a lot of application-specific code can depend on libraries that manage side effects (e.g., for logging or data persistence), and it can be difficult for an engineer to integrate such a library in a testable way without the help of a service layer abstraction.
While we wanted to provide easy-to-setup, battle-tested integrations with the various libraries that are used by teams at Uber, we also wanted to avoid a monolithic framework in order to keep bundle sizes small.
Another reason we prefered a modular approach over our existing monolithic approach is that it forces us to be explicit about dependencies, which makes it easier to avoid common sources of technical debt such as God objects, ad-hoc internal interfaces, and tight coupling.
Fusion.js is the culmination of our efforts.
Who should use Fusion.js?
Fusion.js is a good choice for someone looking for an open source boilerplate to build a modern, non-trivial web app.
On top of the obvious benefits of a pre-configured, optimized boilerplate, Fusion.js also provides a flexible plugin-based architecture. This makes it well-suited to modern single-page applications and web apps that depend on complex service layers to meet quality requirements such as observability (e.g. trace logging, metrics dashboards, etc.), thorough testing (e.g., unit / integration / E2E), and internationalization.
For more on the benefits of Fusion.js, check out our documentation.
The single entry point architecture enables Fusion.js plugins themselves to be universal, too, which allows plugin developers to co-locate snippets of code based on the library the code pertains to, as opposed to the environment the code runs in.
Plugins have access to the HTTP request lifecycle via middlewares and can also access the React tree to add Provider components. They can also initialize browser code.
Ultimately, these qualities make it possible to install a library into an application with a single line of code, regardless of how many different integration points the library requires. Since plugins are easy to add and remove, it also becomes easy to reason about their coupling, impact on bundle size, and other code quality attributes when refactoring. They can also initialize browser code.
Typed dependency injection
Plugins leverage dependency injection, meaning they can expose well-defined APIs as services to other plugins, and a plugin’s dependencies can easily be mocked during tests. This is especially important when dependencies are responsible for communicating with data storage infrastructure or when they relate to observability (e.g., logging, analytics, and metrics).
It’s also possible to ensure type stability statically among dependencies via Flow.js, as depicted below:
One challenge that became apparent years ago was that the popular HTTP server library Express has an API that encourages eager side effects, which made complex response transformations difficult to encapsulate and test. For our previous architecture, application developers often needed to resort to ad-hoc monkey-patching of Express request/response objects and careful colocation of unrelated concerns in ways that only made sense in terms of the order functions needed to be called for things to work as expected. Naturally, given the high coupling of timing requirements for side effect-rich subsystems, testing became extremely difficult.
This problem had been a concern since the design stages of Fusion.js. After much research, we chose to adopt Koa, which provides a more unit-test friendly context-based API, and an elegant and lightweight abstraction for request lifetime management based on the concept of downstreams and upstreams.
As it turns out, the design decisions adopted by Koa complement the design decisions in Fusion.js very well.
Koa middleware provides a logical integration point for React Provider components and the downstream/upstream abstraction aligns perfectly with the lifecycle of the React server-rendered context. Network side effects are decoupled from application logic, improving testability.
The God object and order-of-operation issues that plagued our older apps are now resolved by the Fusion.js dependency injection and graph resolution mechanisms.
In addition to supporting modern testing tools such as Jest, Enzyme, and Puppeteer, Fusion.js also provides tooling for developers to test plugins. The fusion-test-utils package allows mocking the server itself, making it possible to quickly run integration tests between any permutation of plugins and mocks.
Just the beginning
Within Uber, there are already more than 60 repositories using Fusion.js since its internal release. We expect this number to increase rapidly due to a combination of new web projects and automated migration of older projects to Fusion.js. Given this demand, improvements at the framework level should significantly improve the software quality baseline for these projects.
Our roadmap includes adding more performance optimizations and test-oriented tooling, as well as better Flow support.
Subscribe to our newsletter to keep up with the latest innovations from Uber Engineering.