Solving Close's Front End Multi-Tenancy Challenges
This post will explore some frontend challenges posed by hybrid multi-tenancy support in a single page app, and how we’ve addressed them at Close.
What is multi-tenancy?
Multi-tenancy is a concept in application development that involves serving multiple users within a single application while keeping their data and interactions isolated. This approach requires creating features such as customizable branding, role-based access control, and data segmentation to ensure each tenant can operate securely and independently within the shared application environment.
At Close, it means a user can belong to multiple organizations and may need to engage with leads across them throughout the day. Therefore, we need to ensure that we not only support this scenario but also provide a seamless experience for users in doing so. For instance, if a sales rep is on a call with a lead from Organization A and needs to quickly pull up information from Organization B due to an incoming inquiry, our system ensures they can switch contexts without interrupting the call flow or experiencing data delays.
Our app employs a hybrid multi-tenancy approach, where most data remains isolated per organization but some shared elements—such as active calls are accessible across organizations. This ensures consistency in user experience while preserving data security and segmentation within each tenant.
The problem
There are multiple ways a user can switch their active organization in Close, and it also might happen automatically for some specific actions, such as when the user answers inbound calls from leads or opens a specific smart view that belongs to a different organization. Regardless of the trigger, our goal is to provide a seamless experience, with fast transitions that don’t disrupt the user journey.
While much of the code is shared across scenarios, unique challenges arise depending on the user’s page and action. For example, older report pages store data in local states and need specific reset logic to avoid displaying data for the wrong tenant.
Our use case’s complexity stems largely from a codebase over 11 years old. While most of it has been migrated to newer technologies, some pages and components still rely on older libraries (e.g., Backbone) and patterns (e.g., redux-like context states). In summary, we need to update multiple stores, reset tenant-related states, and prevent component rerendering during this process.
Once all data is fully managed within Apollo’s cache, this complexity will be drastically reduced.
The solution
Our Solution: Core
Over the years, we have implemented multiple approaches to handle the
complexities of multi-tenancy, each with different trade-offs. Our current
solution is based on a function (startChangeOrganizationAsync
) to start the
process once the minimal tenant-related data is available, two Apollo reactive
variables (nextOrganizationId
and currentOrganizationId
) to help control the
data flow across different layers of the application, and the function
responsible for updating the multiple stores accordingly. Below is a simplified
version of the org change process.
- The flow starts with the
startChangeOrganizationAsync
function, which checks if the new organization's essential data is available or fetches it otherwise.
const startChangeOrganizationAsync = (orgId) => {
const data = await maybeLoadAndCacheFullOrganization(orgId);
if (!data) {
return false
}
nextOrganizationId(orgId);
return true;
}
- Once completed, it updates the first Apollo variable,
nextOrganizationId
- which our Backbone app controller listens to and executes the primary functionchangeOrganization
. - Besides triggering further requests for tenant-related data, this function
updates the Backbone models and Apollo cache after mapping the new data into
the expected structure. Ultimately, it updates the second Apollo variable,
currentOrganizationId
.
const changeOrganization = (orgId) => {
// a. Trigger subsequent requests to fetch new org's memberships
// b. Massage the org data
// c. Update the Apollo cache and Backbone models
[...]
currentOrganizationId(orgId)
}
onReactiveVarChange(nextOrganizationId, changeOrganization);
onReactiveVarChange
is a small utils function we've created on top of Apollo reactive variables to listen for changes outside of React.
- That variable is listened to by multiple places within the application to perform subsequent actions, for example, fetching the membership details and exposing it all through a React Context state.
const CurrentContextProvider = (children) => {
const orgId = useReactiveVar(currentOrganizationId);
const orgRelatedData = useFetchRemainingOrganizationData(orgId)
<CurrentContext.Provider value={orgRelatedData}>
{children}
</CurrentContext.Provider>
}
Learning and Improving the Solution
Even with this logic in place, some issues still showed up occasionally in the past, depending on the page from which the user was triggering the org change. To address that, two additional changes actions are peformed.
- A new intermediate route specific to this use case was created, whose job was to initiate the flow and navigate the user to the correct path once finished, besides showing a loading indicator to the user in the meantime. The effectiveness of this route is due to the fact no page-specific logic would be running while all the updates are happening behind the scenes. For example, there can't be conflicts with IDs originating from URL params and the stale organization data. Similar to the original, the pseudocode below leverages react-route's loaders feature to start the process.
const changeOrganizationAndReturnPath = () => {
const nextPath = getNewPath(currentPathName)
const willChangeOrganization = await startChangeOrganizationAsync(orgId!);
return nextPath;
}
const clientLoader = () => {
return defer({
nextPath: changeOrganizationAndReturnPath({ params, request }),
})
}
const OrganizationRouteRedirect = () => {
<Suspense fallback={<Loader fullscreen />}>
<Await resolve={data.nextPath}>
{(nextPath) => <Navigate path={nextPath} />}
</Await>
</Suspense>
}
const rrRouteConfig = {
[...]
{
path: '/organization/:orgId/*',
loader: clientLoader,
Component: OrganizationRouteRedirect,
}
}
- Secondly, to clean up the react states we know are specific to a single
organization, we update the
key
prop at one of our route components, remounting the tree below it and consequently resetting these states.
const RouteComponent = () => {
const orgId = useReactiveVar(currentOrganizationId);
return (
[...]
<AppLayout>
<Outlet key={orgId} />
</AppLayout>
[...]
}
Our goal with this setup is to allow our users to interact with any of their organizations at any moment, including, for example, while in the middle of a call, and make sure the system doesn't stay on its way.
Conclusion
This post summarized a common challenge many companies face from an FE perspective, covering the unique aspects of Close generated by the existing long-living technical debt in our codebase and how we're solving it. If you believe you can help us with this and many other similar challenges, keep an eye on our careers page.