● Onboarding guide for new team members

Creation Portal — End-to-End System Design

This single page explains the entire Creation Portal micro-frontend platform: how the host shell loads domain apps at runtime, how they are built, shipped, deployed, and how it all fits together. Everything here is grounded in the actual source of our repositories.

New here? Read in this order: start with the one-line summary and the high-level diagram to get the mental model, then follow §3 Host shell → §4 Loader → §6 Monorepo for the runtime, and finish with §14 CI/CD + §13 Deployment to see how code reaches users. The glossary is your cheat-sheet.
The repositories involved: fe-microfrontend-portal, m3po-mfe-kit, lbr-frontend, rcs-fe-common, yarn-design-system, lbr-jenkins-dsl, lbr-deployments.

0 One-line summary

If you remember one paragraph, remember this one.

The Creation Portal is a runtime-composed micro-frontend platform. A React 18 host shell (fe-microfrontend-portal) owns navigation, auth/permissions, feature flags, global history, and layout, and loads many independently built, versioned, and deployed domain micro-frontends on demand via Module Federation, using a custom loader (@m3po-mfe-kit/mfe-loader, built on @module-federation/runtime). The LBR domain MFEs live in one monorepo (lbr-frontend) with a shared common package, consume a shared component library (yarn-design-system) and shared platform packages (rcs-fe-common), persist per-user grid/view state via a user-preference service, are packaged as Nginx Docker images, deployed to Kubernetes via Kustomize (lbr-deployments), and built/promoted through Jenkins multibranch pipelines generated by Job DSL (lbr-jenkins-dsl). Promotion dev → sit → uat → pre → prod is an immutable image-tag promotion, not a rebuild.

1 The cast of repositories

Each box you'll touch, and what it's for.

RepositoryRoleKey tech
fe-microfrontend-portalThe host shell (portal). Single entry point; owns chrome, routing, auth, feature flags, config loading, browser history.React 18, React Router (BrowserRouter), @m3po-mfe-kit/*, yarn-design-system-icons
m3po-mfe-kitReusable MFE loader toolkit: HostProvider, MicroFrontend, RemoteMicroFrontend, useLoadRemote, global-history.@module-federation/runtime, React Query, TypeScript, Vite
lbr-frontendMonorepo of ~10 LBR domain MFEs + a shared local common package. Each MFE is an independent remote.React 17, Webpack 5 MF, SWC, React Query, i18next, Biome, Jest
rcs-fe-commonShared platform packages (@rcs-fe-common/*): observability, message/error screens, permissions, breadcrumb, notify, router, global-history.npm workspaces + Turbo, Node 24
yarn-design-systemThe shared design-system component library (adidas YARN). Tokens, icons, React components, grid, docs, BFF.Lerna + npm workspaces, SCSS, React
lbr-jenkins-dslCI/CD as code. Job DSL seed jobs that generate the Jenkins folder structure and one multibranch pipeline per app.Groovy Job DSL
lbr-deploymentsKubernetes deployment manifests (Kustomize bases + per-env overlays) for all LBR frontend & backend services.Kustomize, Nginx, Fortanix, Harbor external secrets, VPA

2 High-level architecture diagram

The whole system on one screen. Everything else is a zoom-in on a part of this.

graph TD
    Shell["Portal shell
fe-microfrontend-portal · React 18
BrowserRouter · auth · feature flags"] --> Loader["MFE loader · @m3po-mfe-kit
HostProvider · useLoadRemote"] Loader -. "fetch remoteEntry.js on demand" .-> MFE["Domain micro-frontends · React 17
materials · colors · labels · artwork
hangtags · packaging · basicpattern
lasts · process · libraryselector"] Shell -. "load config at boot" .-> CfgSrv["frontend-config-service
remotes · services · keys"] MFE -. "view preferences" .-> UserPref["user-preference-service
grid columns · facets"] MFE -. "React Query" .-> DomainApis["Domain REST APIs"] MFE --> Common["lbr-frontend/common
MFE container · webpack · requests
i18n · view preferences"] MFE --> YDS["yarn-design-system
host-provided webpack external"] MFE --> RCS["rcs-fe-common
@rcs-fe-common/* packages"] Shell --> RCS DSL["lbr-jenkins-dsl
Job DSL"] --> Jenkins["Jenkins multibranch pipeline
one per MFE"] Jenkins -->|"docker build · push"| Registry["Harbor container registry"] Jenkins -->|"bump image tag"| Deploy["lbr-deployments
Kustomize bases · overlays"] Registry --> K8s["Kubernetes (AWS)
Nginx pods per MFE"] Deploy --> K8s K8s -. "serves remote bundles" .-> MFE classDef runtime fill:#102b42,stroke:#38bdf8,stroke-width:2px,color:#eff8ff classDef services fill:#302343,stroke:#c084fc,stroke-width:2px,color:#f5edff classDef shared fill:#12352f,stroke:#34d399,stroke-width:2px,color:#eafff5 classDef delivery fill:#3b2b16,stroke:#f59e0b,stroke-width:2px,color:#fff7e8 class Shell,Loader,MFE runtime class CfgSrv,UserPref,DomainApis services class Common,YDS,RCS shared class DSL,Jenkins,Registry,Deploy,K8s delivery linkStyle default stroke:#38bdf8,stroke-width:2px

3 The host shell — fe-microfrontend-portal

The portal is the single page application the user actually loads. It owns everything that must be consistent across the platform.

Responsibilities

Global chrome

Header (title "Creation Portal"), nav drawer, notifications, modals, upload panel, inactivity modal, global banner.

Auth / permissions

Resolves user permissions once via getPermissions(...) and shares them down.

Feature flags

featureFlagsService.flags drive conditional UI.

Config loading

Loads which remotes exist + service paths at boot (see §5).

Real browser history

BrowserRouter + a bridge so remotes can drive the URL.

Routing

Builds the route table and mounts the matching remote.

src/containers/app/app.jsx — composition root
// remotes come from the config service (local defaults OR config-server in prod)
const [remotes] = useState(configService.getRemotes());

// map rich remote config down to the minimal shape the loader needs
const mfRemotes = useMemo(() => remotes.map(({ name, entry }) => ({ entry, name })), [remotes]);

// merge static routes with each remote's route
const appRoutes = useMemo(() => {
  const microfrontendRoutes = Object.fromEntries(remotes.map(({ name, route }) => [name, route]));
  return { ...ROUTES, ...microfrontendRoutes };
}, [remotes]);

return (
  <HostProvider hostName="portal" remotes={mfRemotes}>
    <GlobalHistoryBridge />
    <ContextProvider permissions={userPermissions} routes={appRoutes} remotes={remotes} ...>
      <Header title="Creation Portal" />
      <Switch>
        {remotes.map(({ name, route }) => (
          <Route key={name} path={route}><AppContainer /></Route>
        ))}
        <Route path="*"><Home /></Route>
      </Switch>
    </ContextProvider>
  </HostProvider>
);
Global history bridge — lets a remote navigate the real browser URL
function GlobalHistoryBridge() {
  const history = useHistory();
  useGlobalHistoryListener({
    back: history.goBack,
    forward: history.goForward,
    go: history.go,
    push: (path, state) => history.push(path, state),
    replace: (path, state) => history.replace(path, state),
  });
  return null;
}

4 The MFE loader — @m3po-mfe-kit/mfe-loader

The custom package that turns Module Federation into a clean React API. Built on @module-federation/runtime (dynamic, runtime-registered remotes — not build-time shared).

4.1 · HostProvider — creates the MF runtime instance

// contexts/host-provider.tsx
export const HostProvider = ({ remotes, hostName, children }) => {
  const host = useMemo(
    () => createInstance({ name: hostName, remotes }), // from @module-federation/runtime
    [remotes, hostName],
  );
  return <HostContext.Provider value={host}>{children}</HostContext.Provider>;
};

A Remote is either entry-based { name, entry } (a URL to remoteEntry.js) or version-based { name, version }. The portal uses entry-based remotes.

4.2 · useLoadRemote — fetch a remote on demand (cached with React Query)

// hooks/use-load-remote.ts
export const useLoadRemote = (mfeName) => {
  const host = useHost();
  return useQuery({
    queryKey: [RQ_KEY, mfeName],
    queryFn: async () => {
      const module = await host.loadRemote<{ default: CreateRemoteRoot }>(mfeName);
      return module?.default ?? null;   // the remote's exposed factory
    },
    cacheTime: Infinity,
    staleTime: Infinity,
    suspense: true,                     // integrates with <Suspense>
    enabled: !!mfeName,
  });
};

Because cacheTime/staleTime are Infinity, each remote is fetched once and reused for the session.

4.3 · RemoteMicroFrontend — mount the remote into a DOM node

// components/remote-micro-frontend/remote-micro-frontend.tsx
export const RemoteMicroFrontend = ({ containerId, mfeProps, name, ... }) => {
  const { data: createRemoteRoot } = useLoadRemote(name);
  const remoteRootRef = useRef(null);

  const containerCallbackRef = useCallback((node) => {
    if (node) remoteRootRef.current = createRemoteRoot(node); // { render, unmount }
    else remoteRootRef.current?.unmount();
  }, [createRemoteRoot]);

  useLayoutEffect(() => {
    remoteRootRef.current?.render(mfeProps);  // (re)render with latest props
  }, [mfeProps]);

  return <div id={containerId} ref={containerCallbackRef} />;
};

4.4 · MicroFrontend — wraps a remote in its own QueryClientProvider + Suspense

Key pattern: the loader hands each remote a plain DOM node; the remote renders itself into it with its own React. That is what allows the host and remotes to run different React versions.

5 Loading MFE configs — ConfigService

The portal does not hard-code which remotes exist in production. It loads them from a config server at boot.

  • Local/default remotes come from src/config/remotes.js (localhost remoteEntry.js URLs for dev).
  • In production, getConfigServerInfo() fetches /frontend-config-service/portal/rcs-microfe-portal.json and replaces remotes, services, instana, matomo, globalBanner, oauthProxy, asyncNotification.
  • Feature flags load from /portal/rcs-feature-flags.json.
  • Service base paths are centralized and overridable locally via LOCAL_SERVICES_OVERRIDES.
src/services/config.js
async getConfigServerInfo() {
  const { data = {} } = await http.get(`${this.getService(CONFIG_SERVER)}${this.configEndpoints.config}`);
  const { applications, remotes, services, instana, matomo, ... } = data;
  const resolvedRemotes = remotes ?? applications ?? [];
  this.updateServices(services);
  if (ENV.PROD) {
    this.remotes = resolvedRemotes;       // prod remotes come from the server
    this.instana[INSTANA_KEY] = instana?.key;
    this.matomo[MATOMO_SITE]  = matomo?.key;
  }
}
Remote config shape (src/config/remotes.js)
{
  name: 'libraryselector',                 // must match the remote's MF name
  entry: 'http://localhost:8091/remoteEntry.js',
  route: '/libraries',                     // host route that mounts it
  icon: 'libraries-app', label: 'Libraries',
  hide: true,                              // hide from nav drawer
  permissions: 'CP_VIEW_MOS',             // only load if user authorized
  configServerUrl: `/configuration/${EXEC_ENVIRONMENT}-lbr/lbr-app-selector.json`,
  containerId: 'mfe-container-...',        // custom DOM container id
  maintenanceMode: true,                   // short-circuit to maintenance screen
}
Why this matters: new MFEs can be added/toggled per environment without rebuilding the portal — config is data, not code.

6 The LBR MFE monorepo — lbr-frontend

One monorepo (npm workspaces) containing ~10 domain MFEs plus a shared common package.

Hard rules: no cross-MFE imports; all shared code lives in common; imports to common use aliases (@common/*, @root/common/*), never relative paths.

6.1 · Module Federation config (one name per MFE)

common/config/webpack/moduleFederationConfigByPackageName.js
const moduleFederationConfigByPackageName = {
  'lbr-frontend-materials':      { name: 'materials',      exposes: { '.': './src/main.tsx' } },
  'lbr-microfe-library-selector':{ name: 'libraryselector',exposes: { '.': './src/main.tsx' } },
  'lbr-frontend-view-color':     { name: 'colors',         exposes: { '.': './src/main.tsx' } },
  'lbr-microfe-labels':          { name: 'labels',         exposes: { '.': './src/main.tsx' } },
  'lbr-microfe-hangtags':        { name: 'hangtags',       exposes: { '.': './src/main.tsx' } },
  'lbr-microfe-basic-pattern':   { name: 'basicpattern',   exposes: { '.': './src/main.tsx' } },
  'lbr-microfe-packaging':       { name: 'packaging',      exposes: { '.': './src/main.tsx' } },
  'lbr-microfe-artwork':         { name: 'artwork',        exposes: { '.': './src/main.tsx' } },
  'lbr-microfe-lasts':           { name: 'lasts',          exposes: { '.': './src/main.tsx' } },
  'lbr-microfe-process':         { name: 'process',        exposes: { '.': './src/main.tsx' } },
};

Each MFE exposes a single entry (. → ./src/main.tsx).

6.2 · Shared webpack build (common/config/webpack/webpack.common.js)

  • output.libraryTarget: 'umd', chunkFilename: 'chunk-[name].[contenthash].js', publicPath: '/'.
  • swc-loader + ts-loader for TS; CSS Modules (+ .global.scss).
  • ModuleFederationPlugin({ ...config, filename: 'remoteEntry.js', dts: false }).
  • optimization.moduleIds: 'deterministic'; minimizers: CssMinimizerPlugin + TerserPlugin (SWC minify).
  • yarn-design-system and lbr-ag-grid-theme are externals (host-provided → not duplicated into every MFE bundle).

6.3 · Shared MFE container (common/src/components/mfe-container/mfe-container.tsx)

How the host's context reaches a remote. It builds a single mfeProps payload and wraps the remote in an error boundary:

const mfeProps = {
  baseUrl,
  url: `${pathname}${search}${hash}`,
  configServerUrl: config.configServerUrl,
  contexts: { ...contexts, routes: [routes] },  // permissions, breadcrumbs, notifications, routes
  customConfig,
};

<RemoteErrorBoundary remoteName={resolvedRemoteName}>
  <RemoteMicroFrontend name={resolvedRemoteName} containerId={containerId} mfeProps={mfeProps} />
</RemoteErrorBoundary>
  • Failure isolation: RemoteErrorBoundary shows a MessageScreen (LOADING_ERROR) so one broken MFE never crashes the shell.
  • Maintenance mode: if config.maintenanceMode, it renders a maintenance screen + observability event instead of loading the remote.

6.4 · Remote bootstrap (<mfe>/src/remote-render.tsx)

When a remote renders it:

  1. Loads translations (i18next).
  2. In prod, fetches server config (configService.getConfigFromServer).
  3. Extracts contexts (breadcrumbs, permissions: [userInfo, permissionsApi], notifications, routes).
  4. Initializes observability: initObservability({ instanaKey, matomoKey, packageJsonVersion, id }).
  5. Builds the provider tree: StrictMode → QueryClientProvider → RoutesProvider → AppContextProvider → PermissionsProvider → BreadcrumbProvider → NotifyProvider → CustomBreadcrumbsProvider → ViewPreferencesProvider → UserAttributesProvider → UserInfoProvider → AssetUpdateStatusProvider → ImageStoreProvider → App.
  6. Renders inside a MemoryRouter kept in sync with the host URL.
  7. Nested remotes: a domain MFE can itself be a host (e.g. materials declares child remotes via its own <HostProvider hostName="materials-host">). Composition is recursive.

7 Routing & the "two React versions, one router" strategy

  • Host = BrowserRouter (owns window.history, decides which MFE mounts).
  • Each MFE = MemoryRouter (owns its internal pages).
  • A small sync component keeps the MemoryRouter aligned with the host URL:
function UpdateMemoryRouterURL({ url }) {
  const history = useHistory();
  useEffect(() => {
    const cur = history.location.pathname + history.location.search + history.location.hash;
    if (url && cur !== url) history.replace(url);
  }, [url, history]);
  return null;
}

<MemoryRouter initialEntries={[initialEntry]}>
  <UpdateMemoryRouterURL url={url ?? initialEntry} />
  {app}
</MemoryRouter>

React versions: the host is React 18; the LBR MFEs are React 17. This works because integration is at runtime (each remote ships its own React as a UMD bundle and renders into a plain DOM node) — there is no MF shared singletons block forcing one React.

The one deliberate singleton — React Router: to avoid the "two router contexts" bug, a single react-router-dom instance is bridged via a window global:

function ensurePortalRouterDOM() {
  const w = window as Window & { ReactRouterDOM?: typeof ReactRouterDOM };
  w.ReactRouterDOM = { ...ReactRouterDOM, ...w.ReactRouterDOM };
}
Trade-off: everyone must stay aligned on that single router version — version drift is the main operational risk, which is why React/router versions are managed deliberately across the workspace.

8 Shared context across remotes (no coupling)

  • The host resolves auth/permissions, feature flags, breadcrumbs, notifications, routes once.
  • These are passed to each remote only through the mfeProps.contexts payload (§6.3) and the global-history bridge (§3).
  • MFEs never import from each other. Any truly shared logic lives in common or rcs-fe-common.
  • Result: remotes are isolated (independent build/deploy/failure) yet behave as one consistent app.
sequenceDiagram
    participant U as User
    participant H as Portal (React 18)
    participant L as mfe-loader (MF runtime)
    participant C as common/mfe-container
    participant R as Remote MFE (React 17)
    U->>H: open /materials
    H->>H: load config, permissions, flags (ConfigService)
    H->>H: BrowserRouter matches route → AppContainer
    H->>C: render container (remoteName, containerId)
    C->>L: useLoadRemote('materials')
    L->>L: host.loadRemote → fetch remoteEntry.js (cached ∞)
    L-->>C: createRemoteRoot factory
    C->>R: createRemoteRoot(node).render(mfeProps)
    R->>R: bridge router, providers, MemoryRouter, observability
    R->>R: load view preferences (grid config), fetch data (React Query)
    R-->>U: rendered domain screen inside the shell
        

9 User preferences (grid / view config)

Per-user, per-library grid and filter state is handled by a shared provider in common: common/src/contexts/view-preferences.tsx.

ViewPreferencesProvider config
type ViewPreferencesProviderConfig = {
  library: ValueOf<typeof Libraries>;   // which library/domain
  scope?: string;
  domainContext?: string;
  defaultFacetValues?: DefaultFacetValues; // ship a default filter (e.g. hide Archived)
};

What the context exposes: facetSet, facetValues, columnPreferences (grid columns, incl. metadata.pageSize = DEFAULT_PAGE_SIZE), columnSnapshot, domain scope/domainContext, isLoading/isFetching/isError, plus updaters: updateFacetSet, updateFacetValues, updateColumnPreferences, updateDomainScope, forceColumnsUpdate, resetAllFacets.

Persistence: backed by React Query hooks (useGetViewPreferences, useUpdateColumnSet, useUpdateFacetSet, useUpdateFacetValues) that read/write to the user-preference-service (/papp/user-preference-service). An isPersisted flag distinguishes transient (in-session) changes from saved ones.

Why it matters: a user's column layout, page size, and filters follow them across sessions and devices, implemented once in common and reused by every MFE.

10 Shared platform packages — rcs-fe-common

A separate monorepo (npm workspaces + Turbo, Node 24) that publishes the @rcs-fe-common/* packages consumed by the portal and all MFEs.

Packages

observability-utility, message-screen, permissions, breadcrumb, notify, router, global-history, and more.

Build

turbo run build | lint | test (cached, per-package).

Publish

Versioned and published to the private registry.

Role

The platform-abstraction layer — consistent auth, notifications, error screens, observability everywhere.

import * as observabilityClient from '@rcs-fe-common/observability-utility';
import { MessageScreen } from '@rcs-fe-common/message-screen';
import { useGlobalHistoryListener } from '@m3po-mfe-kit/global-history';
rcs-fe-common vs lbr-frontend/common: rcs-fe-common = cross-platform packages shared by many adidas frontends (published to registry). lbr-frontend/common = LBR-local shared code (webpack config, MFE container, request layer, i18n, view-preferences), bundled into each MFE image via Jenkins includePackages: 'common'.
Note: a legacy @rcs-fe-common/fe-microfrontend-loader (script-injection loader) exists but is not used by the portal — the portal uses the m3po loader.

11 Design system — yarn-design-system

A large Lerna + npm workspaces monorepo (adidas YARN design system).

  • Packages: a core (SCSS tokens/base styles), asset packages (icons, logos, fonts, characters), integration wrappers (ag-grid, flatpickr, react-select, swiper, rich-text), the React component layer (yarn-design-system-react-components), a docs site and a BFF.
  • Build: npm run build --workspaces / lerna run build.
  • Publish: independent per-package versioning via lerna exec -- safe-publish.
  • Consumed by LBR as yarn-design-system-react-components + yarn-design-system-icons, treated as a webpack external so 10+ MFEs share one instance (big bundle-size win).
import { appendIconDefinitions } from 'yarn-design-system-icons';
import { Button } from 'yarn-design-system-react-components';

12 Packaging & serving — Docker + Nginx

Each MFE builds its static dist (webpack) plus the bundled common package, and is baked into an Nginx Docker image (Alpine Node base, e.g. alpine-3.23_node-24).

  • Registry: Harbor registry.tools.3stripes.net/ebtpc-rcs/lbr-microfe-* (mirrored to Artifactory). Tags via git describe.
<mfe>/nginx/nginx.template (envsubst-templated)
server {
  listen {{ .Env.NGINX_PORT }};          # 8080
  resolver coredns.kube-system;
  root /usr/share/nginx/html;
  gzip on;
  location / {
    try_files $uri /index.html;          # SPA fallback
  }
}

Logs go to stdout/stderr; daemon off for the container.

13 Kubernetes deployment — lbr-deployments (Kustomize)

Structure: bases + per-environment overlays.

kustomize/
  bases/frontend/microfe-<domain>/
    deploy.yaml                 # Deployment (Nginx)
    service.yaml
    kustomization.yaml
    vpa.yaml                    # Vertical Pod Autoscaler
    harbor-external-secret.yaml # pull secret
    fortanix-secret-store.yaml  # secrets manager
    commonLabelTransformer.yaml
    secrets/fortanix.yaml
  overlays/lbr/<env>/aws/frontend/microfe-<domain>/
    kustomization.yaml          # references base + sets image newTag
    environment.yaml            # env patch
    patch-service.yaml
    patch-harbor-external-secret.yaml
    configuration.yaml
    prefixSuffixTransformer.yaml
    prefixSuffixSecretTransformer.yaml

Environments: dev, sit, uat, pre, prod — each on the aws cluster.

Deployment hardening (deploy.yaml)

  • Image …/lbr-microfe-*:<tag>, imagePullPolicy: Always, replicas: 1, strategy: Recreate.
  • Security: runAsNonRoot, uid/gid 1000, readOnlyRootFilesystem, allowPrivilegeEscalation: false, drop ALL caps, seccompProfile: RuntimeDefault, fsGroup: 1000.
  • Probes: readiness GET /health (8080); liveness tcpSocket; startup exec pgrep nginx.
  • containerPort: 8080; env: CMO_NGINX_*, EXEC_ENVIRONMENT, INSTANA_AGENT_HOST/PORT(42699)/SERVICE_NAME.
  • Resources: limits cpu 250m/mem 256Mi, requests cpu 100m/mem 256Mi.
  • emptyDir volumes for nginx/tmp/logs (root FS is read-only).
Promotion model: an overlay's kustomization.yaml sets the image newTag per environment. Promotion = bump the tag (promote the same immutable image), not a rebuild.
overlays/lbr/dev/aws/frontend/microfe-artwork/kustomization.yaml
resources: [../../../../../../bases/frontend/microfe-artwork]
images:
  - name: registry.tools.3stripes.net/ebtpc-rcs/lbr-microfe-artwork
    newTag: lbr-dev

14 CI/CD as code — lbr-jenkins-dsl + Jenkinsfiles

14.1 · Job DSL generates the Jenkins jobs

lbr-jenkins-dsl contains seed scripts (seed_01_Folders.groovy, seed_01_Jobs.groovy) and per-app files under buildAndDeploy/ that create a multibranch pipeline per app:

List<Map> multibranchCIJobs = [[
  name: getRootPrefixed('BuildAndDeploy/lbrMicrofeBasicPattern/build'),
  displayName: 'Build and Deploy',
  branchSources: [
    github: [ repoOwner: 'bct-adidas', repository: 'lbr-frontend', credentialsId: 'github_lbr' ],
    scriptPath: 'lbr-microfe-basic-pattern/Jenkinsfile'
  ]
]]
jenkinsJobMultibranchPipeline(multibranchCIJobs)

There are DSL files for every frontend MFE and every backend service — the whole LBR CI topology is code.

14.2 · The per-MFE Jenkinsfile

  • frontend component: harbor credentials/registry, imageName: ebtpc-rcs/lbr-microfe-<domain>, includePackages: 'common', Sonar.
  • k8s component: target repo lbr-deployments.git, overlaySuffix: frontend/microfe-<domain>, Fortanix, kubeconfig.
  • Flow: checkout → install → lint (Biome) → test (Jest) → Sonar → webpack build (remoteEntry.js) → Docker build → push to Harbor → update the Kustomize overlay image tag → deploy.

14.3 · Promotion pipeline

Environment promotion is a shared pipeline (com.adidas.cmo.pipeline.Promote) that re-applies the same built image to the next environment by updating its Kustomize overlay:

k8sMap.promoteFrom = params.promoteFrom
k8sMap.promoteTo   = params.promoteTo
k8sMap.clusterTo   = params.clusterTo
withNotifications { withFortanix { withKustomize(k8sMap) { withKustomizeEnv { promote(k8sMap) } } } }

The identical artifact flows dev → sit → uat → pre → prod by tag promotion (prod targets the master branch of the deployments repo).

15 End-to-end flows

15.1 · Build & deploy (one MFE)

graph LR
    A["git push to lbr-frontend
(MFE + common)"] --> B["Jenkins multibranch job
(generated by lbr-jenkins-dsl)"] B --> C["install → lint → test → Sonar"] C --> D["webpack MF build
remoteEntry.js + chunks"] D --> E["Docker build (Alpine Node + Nginx)"] E --> F["push image → Harbor
ebtpc-rcs/lbr-microfe-*"] F --> G["bump image tag in dev overlay
(lbr-deployments)"] G --> H["Kustomize apply → K8s (AWS)
Nginx pod serves new bundle"] H --> I["promote tag: sit → uat → pre → prod"]

15.2 · Runtime (user opens a domain screen)

  1. User loads the portal (React 18).
  2. Portal authenticates, loads feature flags + config (remotes, service paths, keys) via ConfigService.
  3. BrowserRouter matches the URL → mounts AppContainer for the matching remote.
  4. HostProvider + useLoadRemote → host.loadRemote(name) fetches that MFE's remoteEntry.js on demand, cached for the session.
  5. The shared mfe-container builds mfeProps (auth, routes, config, contexts) and mounts the remote in a RemoteErrorBoundary.
  6. The remote (React 17) bootstraps providers, bridges the single react-router-dom, runs its MemoryRouter synced to the host URL, initializes observability (Instana + Matomo), and renders.
  7. The screen loads view preferences (grid columns/facets) and fetches domain data via React Query.
  8. Result: independently deployed MFE behaving as one seamless Creation Portal.

16 Key design decisions & trade-offs

DecisionWhyTrade-off / risk
Runtime Module Federation (not build-time shared)Independent deploy; host & remotes decoupled; add MFEs via configRuntime fetch cost (mitigated by ∞ React Query cache)
Custom m3po loader over raw MFClean React API, Suspense + RQ integration, testableMaintain an internal package
React 18 host + React 17 MFEsTeams upgrade independently; no lockstepMust bridge shared concerns carefully
Single react-router-dom via windowAvoid "two router contexts" bug; unified historyEveryone must align on one router version
Design system + grid theme as externals10+ MFEs share one instance → smaller bundlesHost must provide the exact expected version
Config-server-driven remotesAdd/toggle MFEs per environment without rebuilding the portalConfig correctness becomes operationally critical
Immutable image-tag promotionSame artifact dev→prod; reproducible; fast rollbackRequires disciplined overlay management
Error boundary per remoteOne broken MFE can't crash the shellMust design good fallback UX
Shared common + rcs-fe-commonNo duplication; consistent platform behaviorChanges are high-blast-radius (extra care)
Per-user preferences in a serviceGrid/filter state follows the user; built onceExtra network dependency per screen

17 Performance story

  • Profiled first (did not optimize blindly) using Webpack Bundle Analyzer + Rsdoctor.
  • Tree shaking reduced the bundle from ~2.8 MB → ~1.6 MB.
  • Route-level code splitting + lazy loading of heavy containers/deps (React.lazy + import() behind Suspense).
  • MFE on-demand loading: the host never loads all apps up front — each remote and its routes load as on-demand chunks (chunk-[name].[contenthash].js).
  • Caching: content-hashed immutable assets (long-lived cache), no-cache index.html; React Query caches server responses.
  • Net result: ~50% page-load improvement.
  • Next direction (roadmap): serve bundles from a CDN / edge — origin is Europe-only, so distant users pay latency.

18 Glossary / quick-reference cheat-sheet

Host shell

fe-microfrontend-portal, React 18, title "Creation Portal".

Loader

@m3po-mfe-kit/mfe-loader on @module-federation/runtime; HostProvider → createInstance; useLoadRemote (RQ, suspense, cache ∞); RemoteMicroFrontend → createRemoteRoot(node).render(mfeProps).

MFEs

~10 React 17 remotes in lbr-frontend: materials, colors, labels, hangtags, artwork, packaging, basicpattern, lasts, process, libraryselector. Each exposes { '.': './src/main.tsx' }, UMD, remoteEntry.js.

Config

ConfigService loads /frontend-config-service/portal/rcs-microfe-portal.json (prod remotes) + rcs-feature-flags.json.

Shared context

mfeProps = { baseUrl, url, configServerUrl, contexts, customConfig }.

Routing

Host BrowserRouter; MFE MemoryRouter; single react-router-dom bridged via window.ReactRouterDOM.

User prefs

ViewPreferencesProvider → user-preference-service via React Query.

Platform pkgs

rcs-fe-common (Turbo monorepo, @rcs-fe-common/*).

Design system

yarn-design-system (Lerna, independent versioning, safe-publish, webpack external).

Serving

Nginx (try_files $uri /index.html, port 8080) in Alpine Node image → Harbor ebtpc-rcs/lbr-microfe-*.

Deploy

lbr-deployments Kustomize bases + overlays lbr/{dev,sit,uat,pre,prod}/aws/frontend/microfe-*; hardened Nginx Deployment; tag-promotion.

CI/CD

lbr-jenkins-dsl Job DSL → Jenkins multibranch per MFE; build → Harbor → bump overlay tag → deploy; shared Promote pipeline.

Observability

Instana + Matomo, initialized per-MFE with package version.

Bundle perf

2.8 MB → 1.6 MB (tree shaking) + splitting/lazy + on-demand chunks ≈ 50% faster.