Micro-frontends for Multi-brand Banking Portals
When Module Federation pays off and when it doesn't
When Module Federation pays off and when it doesn't
I build Angular frontends for banks, and a growing share of that work is for groups that run several brands or countries on one platform: a retail brand and a digital-only brand, or the same internet banking portal in three markets with different products and regulators. In almost every one of these programmes, someone proposes micro-frontends with webpack Module Federation. Sometimes that's exactly the right call. Sometimes it adds a distributed system where a well-structured monorepo would have been simpler. This article covers how we tell the two apart, and what it takes to run Module Federation well when it is the right answer.
Module Federation lets one Angular application, the shell (or host), load code from other separately built and deployed applications, the remotes, at runtime. For a banking portal, the shell usually owns login, navigation, layout and session handling, while remotes own business domains: payments, cards, loans, savings and onboarding.
The defining property is independent deployment. The payments team can release a new transfer flow on Tuesday without rebuilding or redeploying the cards module, and the Romanian loans module can ship on its own schedule while the shell stays unchanged. Everything else in this article follows from that property: the benefits, the costs and the ownership questions. If your organisation doesn't need independent deployment, most of Module Federation's complexity buys you nothing.
In multi-brand and multi-country banking, we've found Module Federation is worth its cost when several of these conditions are true at the same time:
Different release cadences that can't be aligned. A country team must ship a regulatory change on a fixed local deadline, while the group release train runs monthly. Without independent deployment, one country's deadline holds up, or gets held up by, everyone else.
Multiple teams or vendors working in parallel. When four or five teams, sometimes from different companies, contribute to one portal, clear runtime boundaries reduce merge conflicts, coordination meetings and accidental coupling.
Country- or brand-specific products. A mortgage module that exists only in one market, or a youth account that exists only in one brand, can be a remote loaded only where it's needed.
A long-lived platform with a large surface. Portals with dozens of journeys and a lifetime measured in years benefit from being able to rewrite or replace one domain without touching the rest.
On the other side, these are the situations where we usually recommend against it:
One team, or teams that already release together. If everything ships in the same release anyway, you pay the runtime complexity without the deployment benefit.
Brands that differ mainly in look and feel. Different colours, logos, typography and copy are a theming problem, not an architecture problem. Design tokens and runtime brand configuration solve it within a single build.
A small portal, with a handful of journeys and one product owner.
No platform team to own the shell, shared dependencies and upgrades. Module Federation without clear ownership of the shared parts tends to drift into exactly the problems described below.
For these cases, a single Nx monorepo with domain libraries is usually the better design. You keep strict module boundaries enforced by lint rules, one build, one deployment and one Angular version, and you can still move to federation later if the organisation changes.
When federation is the right choice, we use @angular-architects/module-federation, which wraps webpack's ModuleFederationPlugin for Angular. The shell declares what it shares, but not which remotes exist:
1 // shell/webpack.config.js
2 const { shareAll, withModuleFederationPlugin } =
3 require('@angular-architects/module-federation/webpack');
4
5 module.exports = withModuleFederationPlugin({
6 shared: {
7 ...shareAll({ singleton: true, strictVersion: true, requiredVersion: 'auto' }),
8 },
9 sharedMappings: ['@bank/design-system', '@bank/auth', '@bank/i18n'],
10 });
Each remote exposes a set of routes, not individual components. This keeps the contract between shell and remote small and stable:
1 // payments/webpack.config.js
2 module.exports = withModuleFederationPlugin({
3 name: 'payments',
4 exposes: {
5 './routes': './src/app/payments.routes.ts',
6 },
7 shared: {
8 ...shareAll({ singleton: true, strictVersion: true, requiredVersion: 'auto' }),
9 },
10 });
The key multi-brand decision is that remote locations come from a manifest served by the backend, per brand and country, rather than being hard-coded in the shell build:
1 // shell/src/main.ts
2 import { initFederation } from '@angular-architects/module-federation';
3
4 const brand = document.documentElement.dataset['brand'];
5
6 initFederation(`/api/portal/manifest?brand=${brand}`)
7 .catch(err => console.error('Federation manifest failed to load', err))
8 .then(() => import('./bootstrap'));
9 {
10 "payments": "https://cdn.bank.example/payments/4.12.0/remoteEntry.js",
11 "cards": "https://cdn.bank.example/cards/2.7.3/remoteEntry.js",
12 "loans-ro": "https://cdn.bank.example/loans-ro/1.3.1/remoteEntry.js"
13 }
14 // shell/src/app/app.routes.ts
15 export const routes: Routes = [
16 {
17 path: 'payments',
18 loadChildren: () =>
19 loadRemoteModule({ type: 'manifest', remoteName: 'payments', exposedModule: './routes' })
20 .then(m => m.PAYMENT_ROUTES),
21 },
22 ];
This gives a bank three useful levers without rebuilding anything:
Which modules each brand gets. The Romanian manifest includes loans-ro; the other markets don't.
Which version each brand runs. A new payments release can go to the digital brand first, then to the retail brand a week later. The rollback is a manifest change.
Versioned, immutable remote URLs. Every deployed remote stays at its own path, so the manifest is a complete, auditable record of what each brand was running at any moment.
Multi-brand portals live or die by the design system. Our rule is: one component library, branded through tokens, shared as a singleton.
1 :root[data-brand='retail'] {
2 --ds-color-primary: #0b3c5d;
3 --ds-radius-control: 4px;
4 --ds-font-family: 'Inter', sans-serif;
5 }
6
7 :root[data-brand='digital'] {
8 --ds-color-primary: #6c2bd9;
9 --ds-radius-control: 12px;
10 --ds-font-family: 'Manrope', sans-serif;
11 }
Components read only tokens, never brand names. Adding a brand means adding a token set and a manifest, not touching component code.
Two practices stop the design system from fragmenting across remotes:
Share it through sharedMappings as a singleton, so every remote on the page uses the same instance. Two copies of the design system on one page mean duplicated CSS, inconsistent components and, with Angular, duplicated services that don't share state.
Keep remotes from styling design-system internals. Overrides from one domain team eventually break another team's screen after a design-system update. If a remote needs something the library doesn't offer, that is a request to the platform team, not a local CSS fix.
Version drift is the trade-off teams underestimate most. With singleton: true and strictVersion: true, all remotes on a page must agree on the versions of Angular, RxJS and the shared libraries. If a remote was built against a different major version, the shared-module check fails at runtime with a version mismatch error.
That strictness is intentional. Running two copies of Angular on the same page is technically possible, but it costs bundle size, breaks shared services and makes behaviour hard to predict. In a banking portal we prefer a clear failure in the test environment to subtle problems in production.
The consequence is that Angular upgrades happen in lockstep across all remotes. Angular releases a major version roughly every six months, so a federated portal needs a planned upgrade process:
The platform team upgrades the shell and shared libraries on a branch, along with a compatibility test suite.
Each domain team upgrades its remote within an agreed window.
Brands switch to the new versions through their manifests, all together, after an integration run that loads every remote in the new combination.
A CI check that compares the Angular version in every remote with the shell's version catches drift early. Without one, the first sign is often a release that fails in the test environment the day before a deadline.
Independent deployment is real for business features. It is not independent for the framework itself, and teams should know that from the start.
Module Federation follows the structure of the organisation, so ownership needs to be explicit before the first remote is built. The model that has worked best for us:
Part | Owner | Responsibilities
Shell | Platform team | Login, session, navigation, layout, error boundaries, manifest service
Design system |Platform team (with design) Components, tokens, accessibility, brand token sets
Shared libraries (auth, i18n, API clients) |Platform team |Stable public APIs, versioning, upgrade notes
Remotes | Domain teams (payments, cards, loans…) |Journeys, domain logic, their own tests and releases
Manifests per brand/country | Release management with each brand |What each brand runs, rollout order, rollback
Three rules make this ownership work in practice:
Remotes don't import from each other. Cross-domain communication goes through the router (for navigation) or through a small, versioned event contract owned by the platform team. A payments remote that imports from cards brings back the coupling federation was meant to remove.
The shell–remote contract is tested. Each remote has a test that loads its exposed routes inside a minimal shell, run in the remote's own pipeline. Breaking changes are found before deployment, not in the integrated environment.
Every remote deployment is a production change. Independent deployment doesn't mean bypassing change management. In regulated banking environments, each remote release goes through the same change, approval and audit trail as any other, which the versioned manifest makes straightforward to evidence.
Loading code at runtime from URLs raises questions that a single-bundle application doesn't:
Content Security Policy. Restrict script-src to the CDN origins that serve your remotes. The manifest service should only ever return URLs from that allowlist.
Manifest integrity. The manifest decides what code runs in the customer's browser, so treat the manifest service like any other production configuration: authenticated changes, an audit log and four-eyes approval.
Failure isolation. If a remote fails to load, the shell should show a clear fallback for that section ("Cards are temporarily unavailable") rather than a blank page. Wrap each remote route with error handling and monitor remote load failures separately.
Performance. Each remote adds at least one extra request for its remoteEntry.js. Preloading the remotes a user is likely to need next, and keeping shared dependencies as singletons, prevents federation from slowing down login and the dashboard.
Before choosing Module Federation for a multi-brand or multi-country portal, we ask five questions:
Mostly yes: Module Federation will likely pay off. Mostly no: start with an Nx monorepo and runtime brand configuration, and keep the module boundaries clean enough to federate later if the answers change.
Module Federation is not a frontend trend to adopt for its own sake. It is a way to match the architecture to an organisation where several teams, brands and countries need to move at different speeds. When that organisation exists, federation, a shared design system and clear ownership let a bank run one platform with many faces. When it doesn't, the simpler architecture is the better-engineered one.