This is a living document. Come back often to get updates.
Code Structuring and the developer setup
Code structuring and dependency management will be explained in detail, in future iterations of this site. They are a very important aspect of developer setup and may influence or propiciate the overall architecture, and many of the strategies and approaches we follow.
In scope of this, we will discuss monorepos, workspaces, and other dependency management tools.
Go to the backlog, to see what other topics are in store!
Modularization or `The Modulith`
Let's start exploring how to solve the monolith problem, with architecture. The first architecture driven solution to all the organizational and technical problems of a monolith that teams have come up with, has been modularization.
This concept is not new, it has been around for a long time, and it's pretty organic to object oriented programming and applications that follow this paradigm.
Modular monoliths are a pretty standard pattern in the industry, to break apart a monolith into modules that can be independently worked on.
The difference between a Modulith and a Micro-* distributed architecture, is that those losely coupled modules are still typically deployed together.
More modern implementations of the modulith make use of higher levels of abstraction to make shared code more reusable, and modules more independent. When it comes to frontend implementations,
Modularization is a very valid and efficient solution to decouple monoliths and, more often than not, the most suitable one.
When it comes to micro-frontends, composable architectures are the successor of the Jamstack -and integrating this concept-, expanding the design options and patterns with the possibility of integrating multiple -frontend- components built on top of different tech stacks. Components may be deployed to independent cloud managed services, or even to different cloud providers.
MACH as a reference, is a set of guidelines for composable architectures to guide teams in the adoption of composability with micro architectures, headless, cloud-native and API-First principles and patterns in mind.
Architecture types that enable decoupled frontends
Because I expect this content to be consumed by folks that are new to web development, as much as seniors, let's start by defining an API. API stands for Application Programming Interface, and it is not just code: an API is a collection of definitions, specifications and protocols that enable the communication between two parties or entities.
On one end the client -the entity making a request for data-, and on the other end, a server, or the entity responding with the data.
APIs are written with different principles in mind, and they can be designed using different models and approaches. There are several types like RESTful, GraphQL, gRPC, tRCP, etc, and much older patterns like SOAP. I will just briefly describe the two first, for now, and will extend with information on other patterns, as I continue to develop this site.
RESTful APIs are desiged following the REST -Representational State Transfer- architectural style. An API that is designed with REST in mind, has to conform to a set of constraints
defined by the following criteria:
There must be a separation of concerns, defined by the client-server relationship, and both entities can be idependently implemented.
The system is stateless, meaning each entity does not know or need to know about the state of the other party at any time, since there is no session information stored.
All responses should be cacheable, to enhance performance, although responses may be marked as non-cacheable, when required.
The system is built in the form of layers, an no party is aware at any time, if they're receiving a response directly from the data source, or a proxy.
Servers are extensible via additional executable code, sent with a response.
An additional constraint requires a uniform interface to manipulate and handle resources, hypermedia, metadata and other messages sent in the response.
To this day, RESTful APIs are the most extensively used architecture paradigm in API design for web development. A recent poll on twitter by Matteo Collina
confirmed that over 70% of the respondents still prefer or use RESTful APIs in their projects.
Pros and Cons of REST
The typical pros and cons of RESTful APIs are:
PRO: it's built on top of a standard protocol -HTTP- to get, save and update resources, so the learning curve is low
PRO: it can also leverage HTTP caching mechanisms, reducing efforts. If you're new to web development, you may have realised that the methods of many API map to HTTP verbs, like GET, POST or PATCH. That's because it's losely based on this protocol.
PRO: allows the implementation of standard OAuth flows for data verification.
CON: the lack of state as a paradigm constraint, is an advantage to scale the system and integrate new clients, but it adds overhead when it comes to development and maintenance of the API.
GraphQL is not an API paradigm, but a query language specially developed for APIs, that allows developers to define a schema for their data using native types object types -like scalars- or custom types.
Developers can then query the data using a resolver function, that can be native or custom. To implement a GraphQL
interface, you need to install a GraphQL client and router that will provide a set of tools and runtime for the API queries.
The most well known GraphQL client is Apollo, although there are other libraries, and even cloud provider specific implementations.
These implementations act as a proxy between the application and the data layer, typically providing an SDK to query a database, directly
from the frontend.
A GraphQL client can be easily integrated with any frontend application and run on a serverless function or execution context, which makes it perfectly suited for
a composable decoupled architecture.
Pros and Cons of GraphQL
The typical pros and cons of RESTful APIs are:
PRO: there is no overfetching. A developer can request exactly the data they need and get just that in the response.
PRO: the schema and the custom data types and resolvers, make the application more robust and resilient.
PRO: the integrated UI for fetching data GraphiQL, simplifies development, testing and documenting the application.
CON: caching is complicated and requires specific development and maintenance.
CON: GraphQL clients are a middleware layer and as such, it requires additional maintenance, and it may be difficult to optimize for performance and to debug, when there are problems.
The more the publicly exposed methods, meaning the API, is very tightly coupled with the business logic and the UI events, the more difficult it becomes to decouple the frontend.
APIs at the core of the system
When designing an API-First system, APIs are the central part of it and that's why it requires a complete mindshift from the monolithic patterns we've been developing with, for the past two decades, or more.
An API-First approach enables system designers to think about the frontend as a dettachable component of the system, sort of like a plug-and-play user interface.
With this approach, the application processing or business logic and transactions become dettachable, too. In essence, an API-First approach make it easier to adopt a service, particularly a SaaS -Software as
a service-, to adopt micro-* patterns -be it micro services or decoupled frontends- and to design a migration in or out of a legacy implementation.
API-First for UX
It's important to keep in mind that some proponents of the API-first approach, promote a system design that effectively starts with API design. When following an `Architecting for the user` approach as I propose it,
design begins with wireframes, design mock-ups (like Figma designs), and user stories, that define the user journey, on top of which we will specify our APIs.
OpenAPI Specification (OAS)
I mentioned before how an API-First pattern requires a mindshift: It requires architects and developers to think about the specification first, and adopt a domain driven design approach when building them at scale.
The OpenAPI Specification makes it possible to standarize aspects like documentation, conventions and best practices that will be used for API design and development, via an API contract or Style Guide, agnostic to any language.
That helps consolidating and enforcing a style
across multiple teams, even when they don't communicate with each other in any other way than accessing the contract.
The OAS itself is a document pushed to the repo where the API lives in, written in a machine readable format -typically JSON or yaml-, in a file
called openapi.* so it can be discovered and read by both other developers or systems, to understand how to maintain and interact with the API.
The OAS as a schema can also be used to automate the provisioning of API gateways, CI/CD pipelines, and the generation of SDK's and testing suites, amongst other things.
An API-First design has many advantages, but it also requires we know how to leverage tools to consolidate and manage a large amount of APIs, in the cloud.
Another architectural pattern that has made it possible to decouple monoliths, including the UI and still preserving strong communication between decoupled components, is the `event-driven` design pattern.
Thinking about event replication and propagation end-to-end, in a distributed system, may require for us to get familiar with more complex technologies, like event hubs, grids, and message brokers. You have probably heard about Apache Kafka or other event streaming technologies,and you may have heard that they have a very steep learning curve -not to mention that they're server-side technologies and require some knowledge of backend languages and/or infra configuration in order to deploy the Confluent Servers -even when you can use the Confluent REST Proxy to consume messages from any frontend client, via the exposed APIs, without knowing anything about Kafka, providing the clusters are in place. But there are also some managed serverless options, that can simplify the integration with an event streaming or pub/sub message bus at a (serverless) backend level, to connect independently deployed UIs.
Let's try to find a way to explain it, in simple words. When we're designing an event-driven architecture, we typically have one or many components that publish events, and one or many components that subscribe to those events. The relationship can be one to many, many to many, or even many to one.
Events are known as messages in a message-driven context (or when the publisher expects a response from the consumer), and they can have topics, which is basically a way to categorize and filter messages. Sometimes, between the publisher or producer of messages, and the subscriber or consumer, there is a broker, that processes and routes those messages.
The important thing: the publisher and subscriber don't need to know of the existence of each other, to operate, and this is why this pattern is so well suited to support composable systems that integrate independent components.
image caption: event driven architecture
Pub/Sub patterns on the client-side
We can also implement a pub/sub mechanism on the client-side, to keep our state manegement and components context awareness consistent, leveraging a web platform native API: window.postMessage().
I have dedicated an entire section to describe native APIs, and how to make the best out of them to simplify development and maintenance efforts and reduce 3rd party dependencies, and make sure a frontend implementation is cross-browser and platform compatible. But I feel the postMessage API deserves a mention together with event-driven design.
Communicating state changes via events
State management is complex to implement even for one application built with a single technology, and it's exponentially more complex when an app is composed of independent micro parts.
Over the years, maintainers and communities have come up with custom state management solutions, that aligned with the development patterns they were promoting. It is my humble opinion that most of them have
been somewhat overengineered and difficult to implement and maintain, resulting in extreme misuse and overuse.
Some modern state management libraries, like Pinia for Vue are doing a great job in simplifying and streamlining state management, and have become more intuitive and maintainable, but they tend to be
bound to a specific framework, and not be useful for horizontal frontend composition with multiple technologies -or a technology different than the one they were built for.
The most effective way to manage state in a horizontal frontend composition scenario, is to leverage web platform native APIs. When composing horizontally with technologies that require a shell, it is also advised to have a specific
team in charge of that orchestration shell, and state management design and execution, including the documentation and maintenance of an API and a global layer to communicate and persist state changes.
We already established that decoupling a monolith helps us solve organizational issues, like teams working on independent features with completely different development cycles, dependencies, release pace, etc.
What kind of options do we have to split those tightly coupled applications, into multiple, composable parts, that we can independently deploy?
To make the right choices we will need to start by understanding the infrastructure and cloud offerings that exist to deploy those smaller and more maneageable apps. We will also need to understand our teams, and the business units they map to, so we can make decoupling decisions that are suitable for each one of those pairs -app type + team type-.
One of the most important decisions to make, in terms of composable decoupled frontends, is the split strategy or whether we will be splitting the frontend horizontally, or vertically. We may also resort to hybrid composition, with a combination of multiple technologies or using innovative patterns like island architecture
Regardless of the approach we take, one important rule is applicable: each micro part should be owned by an independent team and map to a unique business domain or unit.
image caption: Micro-frontends vertical and horizontal splits
The type of split may come determined by the level of encapsulation, security, independent deployability and need for a consistent UI, requirements.
Where do we start?
We start by analyzing our application. What capabilities is it composed of? Can we map those to type of pages or views?
Like for example, a homepage, landing pages, search capability, e-commerce capabilties, blog, etc
Can we classify each one of those views into static or dynamic? Are they being maintained or being further developed?
When an entire section (or domain) of a very large application has completely different requirements and development cycles than another one, and it is developed by a completely independent team, it makes a good use case for vertical splits.
Think for example of a blogging capability of a large online banking platform.
When we're decoupling a monolithic frontend or designing a composable system from the get go, and we split it into multiple fully-fledged applications
each loading at a different URL, we're implementing a vertical split.
Each one of those applications can be a
Single Page App with or without routing
Multi Page App
In both cases, these applications can be small or large, and a composition of multiple components.
The framework used will determine additional patterns and technical decisions: for example, the application html pages can be statically generated at build time, server-side rendered, or they can be client-side rendered.
Additionally, vertical splits can integrate patterns from a typical horizontal split: we may have an MPA that integrates an independent component or several, at one path or route, or several.
The horizontal split represents a pattern that mixes components developed, released, deployed and published by independent teams,
but that are integrated into a single application route or view. The composition can be done at runtime, or at build time.
and it can be part of a single page app, or a multi page app.
The level of complexity and challenges posed by the horizontal splits, are larger than those of the vertical split, particularly when integrating multiple frontend technologies.
Team knowledge ramp
Additional runtime system overload: both CPU and memory requirements
Please note that even when there are uber frameworks that can be used to orchestrate or compose multiple technologies, they represent an additional piece of technology to maintain.
Iframes and micro-frontends
Iframes are obviously not a modern piece of technology. They have been around for a very long time, and they pose some serious challenges, like responsive behavior.
Precisely because they've been around for a while, though, they're an industry proven solution that does suit some use-cases in micro-frontends composition, particularly when there is a priority requirement for encapsulation, including a full out-of-the-box sandbox capability.
And even when they help us encapsulate micro-parts, modern web patform APIs like postMessage, that I discuss here in more detail can be used to communicate between elements loaded in iframes, and the shell or host app.
But, I won't go into a lot of detail, at least for now. The important thing to remember, is that they still may be a valid options, unders some circumstances.
Shared dependencies: a commons or shared assets layer
Sharing core dependencies implies a higher level of interaction between independent teams. Patterns that enable these type of composition and still allow for a certain level of independence are those that exercise a high degree of abstraction of common assets, such as shared stylesheets or modularized, independent common and helper utlilities, typically published to a registry such as npm.
Those common assets are typically developed and maintained by a core team that is in charge of a wrapping shell.
These teams may also choose to work on a centralized monorepo, even when each micro-application is independently built and deployed.
General loading strategies
In order to preserve runtime performance, async loading of components regardless of what type of split we're working with, is advised.
Depending on the framework or ECMA Specification version we're working with, async or deferred loading can be accomplished via a framework specific mechanism, like lazy loading routes in Angular, suspense in React or via native dynamic imports or html script loading attributes or link types.
image caption: loading above the fold
But before, we will need to split our code in meaningful chunks, to design our loading strategy, to satisfy the data architecture or tree of our app.
Code Optimization Techniques
Code optimization techniques are a series of tasks that the build tool, bundler or even the developer will perform to reduce the size of the application code. Some are automated and some are manual, or require pre-configuration or the installation of additional plugins.
Most of them require static analysis of the dependency graph, or the tree of dependencies an application needs to be compiled and run. Most build tools or compilers can only performance static analysis at buildtime.
Code splitting allows you to break the application code into chunks, so we can load them asynchronously or on demand, using loading techniques like dynamic imports or prefetching or preloading, some of which I mentioned before.
Depending on the implementation, we can also leverage a given framework's routing mechanism to load chunks at specific routes.
Deduplication or deduping refers to techniques we can use to prevent fetching the same dependency multiple times. This is particularly useful when we have a dependency graph that has multiple versions of the same dependency.
Additional code optimization techniques to reduce chunks or bundles size, or even the application size entirely, include tree-shaking, dead code elimination, and minification.
I noticed over the years that there is a bit of confusion between tree-shaking and dead code elimination, and even some people think they're the same. However,
these are two different techniques that can be used to optimize the application overall size.
Tree-shaking is an automated step that occurs at buildtime. Via tree-shaking, the software in charge of bundling the application code will implement mechanisms to exclude functions that are not used.
Mechanisms may differ, but typically tree-shaking relies on some form of detection of unused exports and pure functions, that have no side effects.
Even when the process is automated and depends on the tool -ie: webpack may implement tree-shaking in a way and Vite in a another-, we can use best practices to optimize tree-shaking, like naming our functions instead of using anonymous functions, or manually marking a file as side-effect free, that will help the bundler
detect unusued exports.
Dead Code Elimination
Dead code elimination is manually performed as a result of using browser plugins or other tools, to verify code coverage, or what code is actually used at runtime, and then removing it from the codebase or the bundle.
Once the code is ready to be shipped and sent over the network, it should also be minified -meaning removing all unnecessary chracters from the text based files-, and then compressed, -meaning rewriting the binary format file, to reduce the size-.
There are different compression tools that use different algorithms and result in larger or smaller sizes. -Think gzip, br (brotli), etc-
I mentioned before how static code analysis, that is required to optimize code, can only be executed at buildtime. But what if we wanted to analyze our dependency graph to share
dependencies of a horizontal composition, built and deployed independently, at runtime? That is not typically possible for most bundlers.
To mitigate this problem, Webpack introduced the concept of module federation enabled by a specific dedicated plugin that allows the system to perform static analysis of dependencies at runtime, and then share dependencies between scopes.
This approach makes it possible for each module to either provide or consume dependencies from the shareScope, as per additional configuration passed via the plugin configuration object.
Each module configured will be loaded as either host or remote, roles that are interchangeable.
A module that is loaded first will become the host, and will be responsible for loading the remote modules and providing the shared dependencies to the other modules via a global shareScope. There is only one host at a time.
Remotes are loaded immediately after the host and can both consume and provide dependencies from the shareScope. If a dependency required -or the defined version- is not available in the shareScope, they will load their own.
Although Module Federation concepts are very interesting, teams will need to be equipped to solve some important challenges that need to be addressed:
Version skew, typically when versions required by different modules are incompatible
Fate sharing, typically when a bug in one of the modules leads to app malfunction, impacting other modules
Additional efforts to accomplish UI cohesion and consistency
Complexity to manage state and potential state duplication
With independent teams potentially loading any technology to an horizontal split, the requirement for extended technical definitions becomes imperative
Increased complexity to design the infrastructure mechanisms to support deployment
Basically, module federation can solve the static analysis issue, if and when that's a problem for us, providing there are pre-definitions in place to prevent independent teams to load different versions of a library or other dependency and to agree on state management and other common mechanics, to avoid performance and/or version skew issues.
My appreciation is that Module Federation was designed to be implemented on top of a pre-existing component loading system or framework, (so it may not be the original idea to combine versions and frameworks on a horizontal split),
We may understand that the assumption is for modules to be part of a same application framework,
and that the choice of framework version -impacting dependencies compatibility-, state management, etc, is therefore implicit.
This is of course a Next.js specific feature. Multi Zones allow developers to combine multiple deployments -each one mapping or being referred to as a zone- to compose a single application.
I have not used this feature, so I cannot write about the pros and cons or challenges, but it seems to be basically a proprietary implementation of vertical split, that solves for us the challenges of configuring domains to bypass CORS issues,
by merging routing for multiple deployments, after a unique basePath configuration.
It also simplifies infra configuration for teams that work on a monorepository to deploy each app independently.
This website uses a technical cookie to save your cookie preferences in your browser. Additionally, it uses Google Analytics to analyze the traffic.