A net-banking workbench that demonstrates enterprise-grade micro-frontend architecture: one shell dynamically loads three independently developed, independently deployed remotes at runtime via Native Federation v4, sharing a design system and authentication state, backed by a Fastify BFF with a simulated core-banking data layer.
Simulated data disclaimer — Everything in this project is fake: customers, accounts, balances, transactions, OTP codes, and JWT secrets are all generated from a fixed seed for demo purposes. No real financial information or real banking API is involved, and none of the code should be used against real banking systems.
flowchart LR
subgraph Browser
Shell["shell (host)\n:4200 / :8090\nlogin · nav · guards"]
A["mfe-accounts\n:4201 / :8081"]
T["mfe-transfers\n:4202 / :8082"]
AD["mfe-admin\n:4203 / :8083"]
end
M["federation.manifest.json\n(fetched at RUNTIME)"]
BFF["Fastify BFF :3001\nJWT auth · zod validation\nin-memory core banking (seeded)"]
SH["@microbank/shared (singleton)\nzod contracts · auth store · event bus · api client"]
UI["@microbank/ui\nmb-* design system"]
Shell -- "1. fetch manifest" --> M
Shell -- "2. loadRemoteModule('./routes')" --> A
Shell -- "2. loadRemoteModule('./routes')" --> T
Shell -- "2. loadRemoteModule('./routes')" --> AD
A & T & AD & Shell -- "REST + Bearer JWT" --> BFF
A & T & AD & Shell -. "federated singleton" .-> SH
A & T & AD & Shell -. "shared components" .-> UI
Runtime loading flow: the shell's main.ts calls initFederation('federation.manifest.json'), which fetches the manifest at runtime (never baked in at build time), resolves each remote's remoteEntry.json, wires up import maps (es-module-shims), and only then boots Angular. Each route like /transfers lazily calls loadRemoteModule('mfe-transfers', './routes'); if that fails, the route degrades to an error card with retry — the shell and every other remote keep working.
I spent years building net-banking systems for five different banks in Taiwan. The recurring organizational reality: a "bank portal" is never one team's product. Accounts, transfers, wealth management, and the staff back-office are owned by different teams with different release cadences, different risk appetites (a transfer flow change needs far heavier sign-off than a dashboard tweak), and often different vendors. A single deployable front-end turns every release into a cross-team negotiation, and one team's regression blocks everyone's release train.
Micro-frontends map deployment boundaries onto those team boundaries: the transfers team ships when transfers is ready, and a broken admin module degrades to an error card instead of taking the whole portal down (both demonstrated below).
The honest trade-offs — this architecture is expensive:
- Version alignment across shell and remotes becomes an operational discipline, not a
package.jsondetail (see Shared dependency strategy). - Local development needs five processes (or containers) instead of one.
- Cross-remote communication, shared auth, and design consistency all need explicit contracts that a monolith gets for free.
- Debugging spans multiple origins and multiple builds.
When you should not use micro-frontends: a single team, a product where everything releases together anyway, or an app small enough that lazy-loaded Angular routes already give you the modularity you need. In those cases a well-structured monolith with lazy routes delivers the same UX with a fraction of the complexity. Use micro-frontends when organizational scaling is the bottleneck — not for technical novelty.
| Native Federation v4 | Webpack Module Federation | |
|---|---|---|
| Underlying build | esbuild (Angular CLI default builder) | webpack (retired from Angular CLI) |
| Mechanism | Import maps + es-module-shims, remoteEntry.json metadata |
webpack runtime containers, remoteEntry.js |
| Angular alignment | Versioned with Angular (NF 22.x ↔ Angular 22.x), maintained by Angular Architects | Requires keeping webpack in the toolchain Angular has moved away from |
| Standards | Browser-native ES modules | webpack-specific runtime |
This project uses @angular-architects/native-federation v4 (the rewrite that moved to the native-federation/angular-adapter repository, split core into @softarc/native-federation 4.x plus a new orchestrator). Choosing it keeps the stock Angular esbuild pipeline, follows Angular's own major-version cadence, and builds on web standards (import maps) rather than a bundler-specific runtime. Module Federation remains the right call for webpack-locked ecosystems or when you need its mature dynamic-remote tooling — but for a green-field Angular 22 system, Native Federation is the aligned choice.
Configured in each app's federation.config.mjs via shareAll({ singleton: true, strictVersion: true, requiredVersion: 'auto' }):
| Package | Singleton? | Why |
|---|---|---|
@angular/core / common / router |
Must | Two Angular runtimes = two DI containers, broken inject() contexts (NG0203), double change detection |
rxjs |
Must | Cross-instance instanceof/Symbol mismatches break interop |
@microbank/shared |
Must | Holds the auth store and event bus as module-level signals — a second copy silently splits auth state and drops cross-remote events |
@microbank/ui |
Shared (stateless) | Correctness-wise it could be duplicated; sharing it just avoids shipping the design system four times |
Version skew policy: strictVersion: true makes a mismatch a loud runtime error instead of a subtle state-splitting bug. All apps pin the same Angular minor in one pnpm workspace, so skew can't creep in locally; in a real multi-repo setup you would enforce this with a platform BOM (a versions manifest every team consumes) and contract tests in CI. If a remote must ship a newer Angular before the shell, that is a coordinated platform upgrade, not something requiredVersion can paper over.
When a transfer completes in mfe-transfers, mfe-accounts must refresh balances — without a reload. The event bus lives in packages/shared/src/events/event-bus.ts:
- Because
@microbank/sharedis a federated singleton, a plain module-level Angularsignalis already the same object instance in every remote. emitEvent('transfer:completed', payload)in mfe-transfers; mfe-accounts reacts with aneffect()— fully typed, and integrates directly with zoneless/OnPush change detection.
Alternatives considered:
| Approach | Verdict |
|---|---|
| Signals in a shared singleton (chosen) | Typed, zero-boilerplate, plays natively with Angular reactivity. Constraint: only works between remotes that share the module — true here, since all remotes are Angular |
CustomEvent on window |
Framework-agnostic (right call for polyglot remotes), but stringly-typed payloads and manual change-detection wiring |
Shared RxJS Subject |
Works, but adds subscription lifecycle management that signals make unnecessary |
| URL/query params | Great for navigational state, wrong for "data changed" notifications |
apps/
shell/ # host: login, nav, layout, guards, runtime remote loading (4200)
mfe-accounts/ # remote: accounts overview, transactions, detail drawer (4201)
mfe-transfers/ # remote: 5-step transfer wizard + payee CRUD (4202)
mfe-admin/ # remote: staff back-office, lock/unlock + audit log (4203)
bff/ # Fastify BFF, JWT auth, seeded in-memory core banking (3001)
packages/
ui/ # mb-* design system (standalone + signals + OnPush, Tailwind v4 tokens)
shared/ # zod contracts (single source), auth store, event bus, api client
deploy/ # nginx configs + docker runtime manifest
docs/DEVLOG.md # per-stage engineering log (decisions & trade-offs)
Tech: Angular 22 (zoneless, signals, new control flow) · Native Federation 22 (v4) · Fastify 5 · zod 4 · Tailwind CSS 4 · pnpm workspaces · Vitest. TypeScript strict everywhere.
Requires Node ≥ 24 (nvm use) and pnpm 10, plus Docker for the four-origin demo. Zero external dependencies — the BFF seeds an in-memory core bank (fixed seed, fully reproducible).
pnpm install
pnpm dev # BFF :3001 + four dev servers :4200-:4203Open http://localhost:4200 and log in (simulated credentials, OTP is always 000000):
| User | Password | Role |
|---|---|---|
amy |
demo1234 |
customer |
david |
demo1234 |
customer |
staff |
staff1234 |
staff (sees /admin) |
JWTs live in memory only (no localStorage): storage-based tokens are readable by any XSS payload, while memory-only tokens limit the blast radius. The visible trade-off — refreshing the page logs you out — is acceptable for a banking workbench.
pnpm test runs every workspace's Vitest suite; pnpm build builds everything (shared → ui → apps, in dependency order).
This is the core promise of the architecture: redeploy one remote without touching the shell.
# 1. build everything once and start the four origins + BFF
pnpm build
docker compose up -d # shell :8090, remotes :8081-8083, bff :3001
# 2. log in at http://localhost:8090 and open 轉帳中心 (transfers)
# 3. change something visible in mfe-transfers
sed -i '' 's/轉帳中心</轉帳中心 v2</' apps/mfe-transfers/src/app/wizard/transfer-wizard.ts
# 4. rebuild and restart ONLY the transfers origin
pnpm --filter mfe-transfers build
docker compose restart mfe-transfers # ng build recreates dist/, nginx re-resolves the mount
# 5. refresh the browser — "轉帳中心 v2" appears.
# The shell was neither rebuilt nor restarted: verify with
docker inspect -f '{{.State.StartedAt}}' microbank-shell-1The same shell build serves both dev (:4201-4203) and docker (:8081-8083) topologies because deploy/federation.manifest.docker.json is mounted over the baked manifest — remote URLs are runtime configuration, not build-time constants.
Failure degradation drill: docker stop microbank-mfe-admin-1 (or kill the 4203 dev server), then navigate to /admin — an error card with a retry button appears while accounts and transfers remain fully functional.
Every request/response shape is defined once as a zod schema in packages/shared/contracts/; the front-ends derive their types via z.infer, and the BFF validates with the same schema objects. Malformed input gets the unified error envelope:
{ "error": { "code": "VALIDATION_ERROR", "message": "Request validation failed", "details": [ /* zod issues */ ] } }Try it: curl -X POST localhost:3001/auth/login -H 'content-type: application/json' -d '{"username":"amy"}'. Staff-only routes (/admin/*) enforce the staff role server-side; lock/unlock and OTP-reset operations require a reason and append to an audit log.
This repository was built with Claude Code using a deliberately structured harness (visible in .claude/):
- Skills (
.claude/skills/) — four playbooks with hard stop-rules:native-federation(v4 API conventions + a verified reference doc; forbids editing federation config before checking version alignment),design-system,bff-contract(no endpoint before its zod schema exists), andstage-verify(no next stage before tests + manual checks + DEVLOG + semantic commit). - Hooks (
.claude/settings.json) — block writes to.env/*.pem, runtsc --noEmit+ Prettier after every TypeScript edit, and append every tool action to an audit log. - Reviewer subagent (
.claude/agents/reviewer.md) — checks for illegal remote-to-remote imports, shared-version drift, and contract bypasses. - DEVLOG (
docs/DEVLOG.md) — one entry per stage with decisions and trade-offs; the first entry is the up-front verification of the Native Federation v4 API against current docs (training-data memories of the v3 API were explicitly banned). - Every stage was verified end-to-end in a real browser (Playwright against system Chrome) before its semantic commit — which is exactly how the CORS preflight gap and a dist-mount pitfall were caught.
Portfolio project. All data simulated; not affiliated with any bank.