Micro-frontends Overview
Estimated Time: 3 hours | Difficulty: Advanced | Prerequisites: Routing, Lazy Loading, Build Process
Copy
┌─────────────────────────────────────────────────────────────────────────┐
│ Micro-frontend Architecture │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ Host (Shell) │ │
│ │ ┌─────────────────────────────────────────────────────────┐ │ │
│ │ │ Navigation/Layout │ │ │
│ │ └─────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ ┌───────────────┐ ┌───────────────┐ ┌───────────────────┐ │ │
│ │ │ Remote 1 │ │ Remote 2 │ │ Remote 3 │ │ │
│ │ │ Dashboard │ │ Products │ │ Admin │ │ │
│ │ │ (Team A) │ │ (Team B) │ │ (Team C) │ │ │
│ │ └───────────────┘ └───────────────┘ └───────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
│ Benefits: │
│ • Independent deployment • Technology agnostic │
│ • Team autonomy • Smaller, focused codebases │
│ • Incremental upgrades • Parallel development │
│ │
│ Challenges: │
│ • Shared state management • Consistent styling │
│ • Performance overhead • Complex debugging │
│ • Version compatibility • Integration testing │
│ │
└─────────────────────────────────────────────────────────────────────────┘
Module Federation Setup
Using @angular-architects/module-federation
Copy
# Create workspace with multiple apps
npx create-nx-workspace@latest my-mfe --preset=empty
cd my-mfe
# Or with Angular CLI
ng new my-mfe-workspace --create-application=false
cd my-mfe-workspace
# Add host (shell) application
ng generate application shell --routing --style=scss
# Add remote applications
ng generate application dashboard --routing --style=scss
ng generate application products --routing --style=scss
ng generate application admin --routing --style=scss
# Add module federation to each app
ng add @angular-architects/module-federation --project shell --type host --port 4200
ng add @angular-architects/module-federation --project dashboard --type remote --port 4201
ng add @angular-architects/module-federation --project products --type remote --port 4202
ng add @angular-architects/module-federation --project admin --type remote --port 4203
Host (Shell) Configuration
Copy
// projects/shell/webpack.config.js
const { shareAll, withModuleFederationPlugin } = require('@angular-architects/module-federation/webpack');
module.exports = withModuleFederationPlugin({
remotes: {
dashboard: 'http://localhost:4201/remoteEntry.js',
products: 'http://localhost:4202/remoteEntry.js',
admin: 'http://localhost:4203/remoteEntry.js',
},
shared: {
...shareAll({
singleton: true,
strictVersion: true,
requiredVersion: 'auto'
}),
},
});
// projects/shell/src/app/app.routes.ts
export const routes: Routes = [
{
path: '',
loadComponent: () => import('./home/home.component').then(m => m.HomeComponent)
},
{
path: 'dashboard',
loadChildren: () => import('dashboard/routes').then(m => m.DASHBOARD_ROUTES)
},
{
path: 'products',
loadChildren: () => import('products/routes').then(m => m.PRODUCTS_ROUTES)
},
{
path: 'admin',
loadChildren: () => import('admin/routes').then(m => m.ADMIN_ROUTES),
canMatch: [adminGuard]
}
];
// projects/shell/src/decl.d.ts
declare module 'dashboard/routes';
declare module 'products/routes';
declare module 'admin/routes';
Remote Configuration
Copy
// projects/dashboard/webpack.config.js
const { shareAll, withModuleFederationPlugin } = require('@angular-architects/module-federation/webpack');
module.exports = withModuleFederationPlugin({
name: 'dashboard',
exposes: {
'./routes': './projects/dashboard/src/app/dashboard.routes.ts',
'./DashboardComponent': './projects/dashboard/src/app/dashboard/dashboard.component.ts',
},
shared: {
...shareAll({
singleton: true,
strictVersion: true,
requiredVersion: 'auto'
}),
},
});
// projects/dashboard/src/app/dashboard.routes.ts
import { Routes } from '@angular/router';
export const DASHBOARD_ROUTES: Routes = [
{
path: '',
loadComponent: () => import('./dashboard/dashboard.component')
.then(m => m.DashboardComponent)
},
{
path: 'analytics',
loadComponent: () => import('./analytics/analytics.component')
.then(m => m.AnalyticsComponent)
},
{
path: 'reports',
loadComponent: () => import('./reports/reports.component')
.then(m => m.ReportsComponent)
}
];
Dynamic Remote Loading
Copy
// shell/src/app/services/mfe-registry.service.ts
import { loadRemoteModule } from '@angular-architects/module-federation';
export interface MfeConfig {
name: string;
remoteEntry: string;
exposedModule: string;
displayName: string;
route: string;
icon?: string;
}
@Injectable({ providedIn: 'root' })
export class MfeRegistryService {
private http = inject(HttpClient);
private router = inject(Router);
private mfes = signal<MfeConfig[]>([]);
readonly availableMfes = this.mfes.asReadonly();
async loadRegistry(): Promise<void> {
// Load MFE configuration from backend
const config = await firstValueFrom(
this.http.get<{ mfes: MfeConfig[] }>('/api/mfe-config')
);
this.mfes.set(config.mfes);
this.registerRoutes(config.mfes);
}
private registerRoutes(mfes: MfeConfig[]): void {
const routes: Routes = mfes.map(mfe => ({
path: mfe.route,
loadChildren: () => loadRemoteModule({
type: 'module',
remoteEntry: mfe.remoteEntry,
exposedModule: mfe.exposedModule
}).then(m => m.routes)
}));
// Add routes dynamically
this.router.resetConfig([
...this.router.config.filter(r => r.path !== '**'),
...routes,
{ path: '**', redirectTo: '' }
]);
}
}
// shell/src/app/app.component.ts
@Component({
template: `
<app-header />
<nav class="sidebar">
@for (mfe of registry.availableMfes(); track mfe.name) {
<a [routerLink]="['/', mfe.route]" routerLinkActive="active">
@if (mfe.icon) {
<mat-icon>{{ mfe.icon }}</mat-icon>
}
{{ mfe.displayName }}
</a>
}
</nav>
<main>
<router-outlet />
</main>
`
})
export class AppComponent implements OnInit {
registry = inject(MfeRegistryService);
async ngOnInit() {
await this.registry.loadRegistry();
}
}
Shared State & Communication
Shared State Service
Copy
// shared-lib/src/lib/shared-state.service.ts
import { Injectable, signal, computed } from '@angular/core';
export interface User {
id: string;
name: string;
email: string;
roles: string[];
}
export interface GlobalState {
user: User | null;
theme: 'light' | 'dark';
notifications: Notification[];
cart: CartItem[];
}
@Injectable({ providedIn: 'root' })
export class SharedStateService {
private state = signal<GlobalState>({
user: null,
theme: 'light',
notifications: [],
cart: []
});
// Selectors
readonly user = computed(() => this.state().user);
readonly isAuthenticated = computed(() => this.state().user !== null);
readonly theme = computed(() => this.state().theme);
readonly notifications = computed(() => this.state().notifications);
readonly cart = computed(() => this.state().cart);
readonly cartCount = computed(() => this.state().cart.length);
readonly cartTotal = computed(() =>
this.state().cart.reduce((sum, item) => sum + item.price * item.quantity, 0)
);
// Actions
setUser(user: User | null) {
this.state.update(s => ({ ...s, user }));
}
setTheme(theme: 'light' | 'dark') {
this.state.update(s => ({ ...s, theme }));
document.documentElement.classList.toggle('dark', theme === 'dark');
}
addNotification(notification: Notification) {
this.state.update(s => ({
...s,
notifications: [...s.notifications, notification]
}));
}
removeNotification(id: string) {
this.state.update(s => ({
...s,
notifications: s.notifications.filter(n => n.id !== id)
}));
}
addToCart(item: CartItem) {
this.state.update(s => {
const existing = s.cart.find(i => i.productId === item.productId);
if (existing) {
return {
...s,
cart: s.cart.map(i =>
i.productId === item.productId
? { ...i, quantity: i.quantity + item.quantity }
: i
)
};
}
return { ...s, cart: [...s.cart, item] };
});
}
removeFromCart(productId: string) {
this.state.update(s => ({
...s,
cart: s.cart.filter(i => i.productId !== productId)
}));
}
clearCart() {
this.state.update(s => ({ ...s, cart: [] }));
}
}
Event Bus for Cross-MFE Communication
Copy
// shared-lib/src/lib/event-bus.service.ts
import { Injectable } from '@angular/core';
import { Subject, Observable, filter, map } from 'rxjs';
export interface MfeEvent<T = any> {
type: string;
source: string;
payload: T;
timestamp: Date;
}
@Injectable({ providedIn: 'root' })
export class EventBusService {
private events$ = new Subject<MfeEvent>();
emit<T>(type: string, payload: T, source: string): void {
this.events$.next({
type,
source,
payload,
timestamp: new Date()
});
}
on<T>(type: string): Observable<MfeEvent<T>> {
return this.events$.pipe(
filter(event => event.type === type)
);
}
onMultiple<T>(types: string[]): Observable<MfeEvent<T>> {
return this.events$.pipe(
filter(event => types.includes(event.type))
);
}
// Predefined events
emitNavigation(path: string, source: string) {
this.emit('navigation', { path }, source);
}
emitUserAction(action: string, data: any, source: string) {
this.emit('userAction', { action, data }, source);
}
emitError(error: Error, source: string) {
this.emit('error', { message: error.message, stack: error.stack }, source);
}
}
// Usage in remote MFE
@Component({...})
export class ProductDetailComponent {
private eventBus = inject(EventBusService);
private sharedState = inject(SharedStateService);
addToCart(product: Product) {
// Update shared state
this.sharedState.addToCart({
productId: product.id,
name: product.name,
price: product.price,
quantity: 1
});
// Emit event for other MFEs
this.eventBus.emit('cart:itemAdded', {
productId: product.id,
productName: product.name
}, 'products');
}
}
// Listening in shell
@Component({...})
export class HeaderComponent {
private eventBus = inject(EventBusService);
private snackBar = inject(MatSnackBar);
constructor() {
this.eventBus.on<{ productName: string }>('cart:itemAdded').subscribe(event => {
this.snackBar.open(`${event.payload.productName} added to cart`, 'View Cart', {
duration: 3000
});
});
}
}
Shared UI Library
Copy
// shared-ui/src/lib/components/button/button.component.ts
@Component({
selector: 'shared-button',
standalone: true,
template: `
<button
[class]="buttonClass()"
[disabled]="disabled()"
[type]="type()"
>
@if (loading()) {
<shared-spinner size="small" />
}
<ng-content />
</button>
`
})
export class SharedButtonComponent {
variant = input<'primary' | 'secondary' | 'danger'>('primary');
size = input<'small' | 'medium' | 'large'>('medium');
disabled = input(false);
loading = input(false);
type = input<'button' | 'submit'>('button');
buttonClass = computed(() => {
return `btn btn-${this.variant()} btn-${this.size()}`;
});
}
// shared-ui/src/lib/components/card/card.component.ts
@Component({
selector: 'shared-card',
standalone: true,
template: `
<div class="card" [class.card-elevated]="elevated()">
@if (title()) {
<div class="card-header">
<h3>{{ title() }}</h3>
<ng-content select="[card-actions]" />
</div>
}
<div class="card-body">
<ng-content />
</div>
@if (hasFooter) {
<div class="card-footer">
<ng-content select="[card-footer]" />
</div>
}
</div>
`
})
export class SharedCardComponent {
title = input<string>();
elevated = input(false);
@ContentChild('card-footer') hasFooter = false;
}
// Export all shared components
// shared-ui/src/index.ts
export * from './lib/components/button/button.component';
export * from './lib/components/card/card.component';
export * from './lib/components/spinner/spinner.component';
export * from './lib/components/modal/modal.component';
export * from './lib/components/table/table.component';
export * from './lib/components/form-field/form-field.component';
Deployment Strategy
Copy
# docker-compose.mfe.yml
version: '3.8'
services:
shell:
build:
context: ./projects/shell
dockerfile: Dockerfile
ports:
- "4200:80"
environment:
- DASHBOARD_URL=http://dashboard:80
- PRODUCTS_URL=http://products:80
- ADMIN_URL=http://admin:80
depends_on:
- dashboard
- products
- admin
dashboard:
build:
context: ./projects/dashboard
dockerfile: Dockerfile
ports:
- "4201:80"
products:
build:
context: ./projects/products
dockerfile: Dockerfile
ports:
- "4202:80"
admin:
build:
context: ./projects/admin
dockerfile: Dockerfile
ports:
- "4203:80"
nginx:
image: nginx:alpine
ports:
- "80:80"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
depends_on:
- shell
- dashboard
- products
- admin
Copy
# nginx.conf for micro-frontends
upstream shell {
server shell:80;
}
upstream dashboard {
server dashboard:80;
}
upstream products {
server products:80;
}
upstream admin {
server admin:80;
}
server {
listen 80;
location / {
proxy_pass http://shell;
}
location /dashboard/ {
proxy_pass http://dashboard/;
}
location /products/ {
proxy_pass http://products/;
}
location /admin/ {
proxy_pass http://admin/;
}
# Remote entries
location ~ ^/(.+)/remoteEntry.js$ {
set $mfe $1;
proxy_pass http://$mfe/remoteEntry.js;
}
}
Best Practices
Share Wisely
Only share what’s necessary - over-sharing leads to coupling
Version Compatibility
Test shared dependency versions across all MFEs
Consistent Styling
Use a shared design system with CSS custom properties
Error Boundaries
Isolate MFE failures to prevent cascade effects
Copy
// Error boundary wrapper
@Component({
selector: 'mfe-error-boundary',
template: `
@if (hasError()) {
<div class="mfe-error">
<mat-icon>error_outline</mat-icon>
<h3>Something went wrong</h3>
<p>The {{ mfeName() }} module failed to load.</p>
<button mat-button (click)="retry()">Retry</button>
</div>
} @else {
<ng-content />
}
`
})
export class MfeErrorBoundaryComponent implements OnInit, ErrorHandler {
mfeName = input.required<string>();
hasError = signal(false);
handleError(error: Error): void {
console.error(`MFE Error [${this.mfeName()}]:`, error);
this.hasError.set(true);
}
retry() {
this.hasError.set(false);
}
}
Next: Nx Monorepo
Scale your Angular projects with Nx monorepo tooling