The problem:
At my company Conio we have a internal backoffice used for user management. But we need to provide the same dashboard to our partners so that they can manage their user base. Unfortunately each partner is different in "UI" and "Api request/response":
For the first group I can split in:
Partner X wants "red" as primary color, and Y wants "yellow".
Partner X show "widget-A", and Y show only "widget-B"
For the second group I can split in different scenario but this article will cover only a scenario where the api request for a getUser (with different response and different request since we use GraphQL:
for the api
/getUser
Partner X,Y receive a response of type
// response on /api/PartnerX/getUser
export interface Order {
id: string
name: string
isOtc: boolean
}
//response on /api/partnerY/getUser
export interface Order {
id: string
name: string
}
This is a common scenario because normally a feature comes out in before in our backoffice and later (also years) in partners.
We can also think to put isOtc
as optional field. This way is fast and simple. But after we need to add typescript check even if we know that isOtc is defined. And typescript code can explode in a scenario with others 20 apis and 30 different others interfaces.
The solution (my solution):
The solution I will propose fit my requirements and my goal. I don't think is a perfect solution. Every idea is a tradeoff with pro and cons.
My pro points are:
I switched from to have N branch for N partner to 1 single branch for all partners
I can run "npm run build:partner-X" or "npm run build:partner-Y"
I have a SINGLE typescript config that works well and reflect 100% for the api schema
My cons points are:
I need to put more attention when develop a new feature
I have "duplicated" interfaces in some cases (where request/response is different)
So, my backoffice project is a pure react client app (no server side) and is built on top of vitejs.
I have a SINGLE vite config:
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
import path from "path";
export default defineConfig(({ mode }) => {
const mapping = {
partnerX-staging: "X",
partnerX-prod: "X",
partnerY-staging: "Y",
partnerY-prod: "Y",
};
const partner = mapping[mode] || process.env.MODE;
if (!partner) {
throw new Error(`Partner not found for mode: ${mode} in mapping`);
}
return {
plugins: [react()],
envPrefix: "REACT_APP",
resolve: {
alias: {
src: path.resolve(__dirname, "src"),
"@partner": path.resolve(__dirname, `src/packages/partners/${partner}`),
},
},
build: {
outDir: "build",
},
server: {
port: 3000,
},
preview: {
port: 3000,
},
};
});
In details:
preview, server: I like to use port 3000 but this is not related with the goal of this article
endPrefix: same behavior
mapping: Is a dictionary. The keys are the "environments". Probably we have production/staging env for a partner. The values are folders that use row 25 with "@partner"
And my package.json is:
{
"name": "backoffice",
"version": "1.34.1",
"private": true,
"scripts": {
"set-partner": "node setup-ts-config.js --partner",
"type-checking": "tsc",
"build": "vite build",
"build:env": "yarn run type-checking && yarn run build --mode",
"build:partnerX-prod": "yarn set-partner partnerX && yarn run build:env partnerX-prod",
"build:partnerX-staging": "yarn set-partner partnerY && yarn run build:env partnerX-staging",
"build:partnerY-prod": "yarn set-partner partnerY && yarn run build:env partnerY-prod",
"build:partnerY-staging": "yarn set-partner partnerY && yarn run build:env partnerY-staging",
"start": "yarn set-partner partnerX && vite --mode partnerX-staging",
}
}
In details:
set-partner: this command will edit the field "paths" inside tsconfig.json. We see it later.
build:env: dynamic command that expect a "mode"
build:partnerX-prod: this command set partnerX, and start a build. This build will use the files under /X folder and will use the staging env variables.
start: command used for normal development. With this command we want to run dev mode for partnerX with staging variables
My setup-ts-config.js is
const fs = require("node:fs");
const args = process.argv.slice(2);
const partnerIndex = args.findIndex((arg) => arg === "--partner");
const partner = partnerIndex !== -1 ? args[partnerIndex + 1] : null;
if (!partner) {
throw new Error("Please provide a partner name using --partner <partner-name>");
}
const tsConfig = JSON.parse(fs.readFileSync("tsconfig.json", "utf-8"));
tsConfig.compilerOptions.paths = {
"@partner/*": [`src/packages/partners/${partner}/*`],
};
fs.writeFileSync("tsconfig.json", JSON.stringify(tsConfig, null, 2), "utf-8");
And I have N files with name ".env.${partner} like:
.env.partnerX-prod
.env.partnerX-staging
.env.partnerY-prod
...etc
Now I can show you a common interface
// src/packages/core/user/types.ts
import { UserX } from "src/packages/partners/X/user/types";
import { UserY } from "src/packages/partners/Y/user/types";
export interface BasicCommonUser {
id: string
name: string
}
export type User = UserX | UserY;
And I can have specific interface for X
// src/packages/partners/X/user/types.ts
import { BasicCommonUser } from "src/packages/core/user/types";
export interface UserX extends BasicCommonUser {
isOtc: boolean
}
And I can specific interface for Y (for example obsoleted interface)
// src/packages/partners/Y/user/types.ts
import { BasicCommonUser } from "src/packages/core/user/types";
export interface UserY extends BasicCommonUser {}
In this way we can use User like:
// src/components/MyWidgetUser.tsx
import { User } from "src/packages/core/user/types";
export default function(user: User){
return <p>hi {user.name}</p>
}
In this way when we run/build with partner X, we can write "user.name" and with partner Y "user.name" generates type error 😀.
If we want more control, for example we can Typescript TypeGuards:
// src/packages/core/user/types.ts
//... previus code
export const isUserX = (user: User): user is UserX => {
return (
"isOtc" in user
);
};
and use like that:
// DynamicWidgetUser.tsx
export default function(user: User){
if(isUserX(user)){
return <p>{user.name} is otc: {user.isOtc}</p>
} else {
return <p>{user.name}</p>
}
}
I love GrapQL and we use it. But since UserX has got "isOtc" in the real work the queries from X and Y are different.
So in my case for X I have
// src/packages/partners/X/getUser.ts
export const GET_USER = () => gql`
fragment UserSchemaFields on UserSchema {
name
isOtc
}
query user {
user(user_id: $user_id) {
...UserSchemaFields
}
}
`
and for Y I have
// src/packages/partners/Y/getUser.ts
export const GET_USER = () => gql`
fragment UserSchemaFields on UserSchema {
name
}
query user {
user(user_id: $user_id) {
...UserSchemaFields
}
}
`
I use this api like that:
// src/components/Header.tsx
import { GET_USER } from "@partner/getUser";
export default function(){
const { data } = useQuery(GET_USER)
}
And this works since "@partner" points to one of existing folder (tsconfig.json knows what)
All this code show you how much customization you can do with this configuration. In my real work UserX has got more than 15 fields and UserY about 10. So X has got 5 more field and 3/5 are optional 😀.