Angular Micro Frontends
Introduction
In modern web development, as applications grow in size and complexity, teams often face challenges with maintaining large codebases, enabling independent deployments, and scaling development across multiple teams. Micro frontends offer a solution to these challenges by applying microservice principles to frontend applications.
This guide will introduce you to micro frontends in Angular, showing you how to break down a monolithic frontend application into smaller, more manageable pieces that can be developed, tested, and deployed independently.
What are Micro Frontends?
Micro frontends are an architectural pattern where a frontend application is decomposed into semi-independent smaller applications, each responsible for a distinct feature or business domain. These smaller applications are developed and deployed independently, but together they form a cohesive user experience.
Key Benefits of Micro Frontends:
- Independent Development: Teams can work on different parts of the application without interfering with each other
- Technology Diversity: Different micro frontends can use different frameworks or versions
- Incremental Upgrades: Update or replace parts of your application without rebuilding everything
- Scalable Development: Multiple teams can work in parallel on different features
Angular Approaches to Micro Frontends
Several approaches exist for implementing micro frontends with Angular. Let's explore the most common ones:
1. Module Federation (Webpack 5)
Module Federation, introduced in Webpack 5, is arguably the most powerful approach for implementing micro frontends. It allows multiple independent builds to form a single application, with each build acting as a container that can export and consume modules from other builds.
Setting up Module Federation in Angular:
First, install the necessary dependencies:
ng add @angular-architects/module-federation
Then, configure the host application:
// webpack.config.js for host application
const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin');
module.exports = {
// ...webpack config
plugins: [
new ModuleFederationPlugin({
name: 'host',
remotes: {
mfe1: 'mfe1@http://localhost:4201/remoteEntry.js',
},
shared: {
'@angular/core': { singleton: true },
'@angular/common': { singleton: true },
'@angular/router': { singleton: true }
}
})
]
};
Configure the remote application:
// webpack.config.js for remote application
const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin');
module.exports = {
// ...webpack config
plugins: [
new ModuleFederationPlugin({
name: 'mfe1',
filename: 'remoteEntry.js',
exposes: {
'./ProductModule': './src/app/product/product.module.ts',
},
shared: {
'@angular/core': { singleton: true },
'@angular/common': { singleton: true },
'@angular/router': { singleton: true }
}
})
]
};
Then, set up lazy loading for the remote module in your routes:
// app-routing.module.ts in host application
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
const routes: Routes = [
{
path: 'products',
loadChildren: () => import('mfe1/ProductModule')
.then(m => m.ProductModule)
}
];
@NgModule({
imports: [RouterModule.forRoot(routes)],
exports: [RouterModule]
})
export class AppRoutingModule { }
2. Web Components
Web Components are a set of standardized browser APIs that allow you to create custom, reusable HTML elements. Angular elements package allows you to convert Angular components into Web Components.
Creating an Angular Element:
// product-card.component.ts
import { Component, Input } from '@angular/core';
@Component({
selector: 'app-product-card',
template: `
<div class="card">
<h3>{{name}}</h3>
<p>{{description}}</p>
<p class="price">{{price | currency}}</p>
</div>
`,
styles: [`
.card { border: 1px solid #ddd; padding: 15px; }
.price { font-weight: bold; }
`]
})
export class ProductCardComponent {
@Input() name: string;
@Input() description: string;
@Input() price: number;
}
Convert the component to a custom element:
// main.ts
import { createCustomElement } from '@angular/elements';
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { AppModule } from './app/app.module';
import { ProductCardComponent } from './app/product-card.component';
import { Injector } from '@angular/core';
platformBrowserDynamic()
.bootstrapModule(AppModule)
.then(moduleRef => {
const injector = moduleRef.injector;
const productCardElement = createCustomElement(ProductCardComponent, { injector });
customElements.define('product-card', productCardElement);
});
Now you can use this web component in any application:
<product-card
name="Awesome Product"
description="This product is amazing!"
price="29.99">
</product-card>
3. IFrames
While not as elegant as other solutions, iframes offer perfect isolation between micro frontends:
<div class="container">
<iframe
src="http://localhost:4201/products"
title="Products App"
width="100%"
height="500px"
frameborder="0">
</iframe>
</div>
Communication Between Micro Frontends
Micro frontends need to communicate with each other. Here are some common patterns:
1. Custom Events
// In micro frontend A
const event = new CustomEvent('productSelected', {
detail: { productId: 123, name: 'Awesome Product' },
bubbles: true
});
document.dispatchEvent(event);
// In micro frontend B
document.addEventListener('productSelected', (event) => {
console.log('Selected product:', event.detail);
});
2. Shared State Library
Using a state management solution like NgRx with a shared state:
// shared-state.actions.ts
import { createAction, props } from '@ngrx/store';
export const selectProduct = createAction(
'[Product] Select Product',
props<{ productId: number, name: string }>()
);
// In consumer micro frontend
import { Store } from '@ngrx/store';
import { selectProduct } from './shared-state.actions';
@Component({/*...*/})
export class ProductListComponent {
constructor(private store: Store) {}
onSelectProduct(product: Product) {
this.store.dispatch(selectProduct({
productId: product.id,
name: product.name
}));
}
}
Implementing a Real-World Example
Let's build a simple e-commerce application with two micro frontends:
- Product Catalog micro frontend
- Shopping Cart micro frontend
Step 1: Set up the Host Application
ng new micro-frontend-host --routing
cd micro-frontend-host
ng add @angular-architects/module-federation --project micro-frontend-host --port 4200
Configure the host webpack.config.js
:
const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin');
module.exports = {
// ...other webpack config
plugins: [
new ModuleFederationPlugin({
name: 'host',
remotes: {
productApp: 'productApp@http://localhost:4201/remoteEntry.js',
cartApp: 'cartApp@http://localhost:4202/remoteEntry.js'
},
shared: {
'@angular/core': { singleton: true },
'@angular/common': { singleton: true },
'@angular/router': { singleton: true },
'@angular/material': { singleton: true }
}
}),
]
};
Step 2: Set up the Product Catalog Micro Frontend
ng new product-catalog --routing
cd product-catalog
ng add @angular-architects/module-federation --project product-catalog --port 4201
Configure the product app webpack.config.js
:
const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin');
module.exports = {
// ...other webpack config
plugins: [
new ModuleFederationPlugin({
name: 'productApp',
filename: 'remoteEntry.js',
exposes: {
'./ProductModule': './src/app/product/product.module.ts',
},
shared: {
'@angular/core': { singleton: true },
'@angular/common': { singleton: true },
'@angular/router': { singleton: true },
'@angular/material': { singleton: true }
}
}),
]
};
Create the product module:
// product.module.ts
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule } from '@angular/router';
import { ProductListComponent } from './product-list/product-list.component';
import { ProductDetailComponent } from './product-detail/product-detail.component';
@NgModule({
declarations: [
ProductListComponent,
ProductDetailComponent
],
imports: [
CommonModule,
RouterModule.forChild([
{ path: '', component: ProductListComponent },
{ path: 'detail/:id', component: ProductDetailComponent }
])
]
})
export class ProductModule { }
Step 3: Set up the Shopping Cart Micro Frontend
Follow similar steps to set up the cart micro frontend.
Step 4: Update Host Application to Load Micro Frontends
// app-routing.module.ts in host
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { HomeComponent } from './home/home.component';
const routes: Routes = [
{ path: '', component: HomeComponent },
{
path: 'products',
loadChildren: () => import('productApp/ProductModule')
.then(m => m.ProductModule)
},
{
path: 'cart',
loadChildren: () => import('cartApp/CartModule')
.then(m => m.CartModule)
}
];
@NgModule({
imports: [RouterModule.forRoot(routes)],
exports: [RouterModule]
})
export class AppRoutingModule { }
Step 5: Setup Cross-Micro Frontend Communication
Create a shared event service:
// shared-events.service.ts
import { Injectable } from '@angular/core';
import { Subject } from 'rxjs';
export interface CartEvent {
type: 'ADD_TO_CART' | 'REMOVE_FROM_CART';
product: any;
}
@Injectable({ providedIn: 'root' })
export class SharedEventsService {
private cartEvents = new Subject<CartEvent>();
cartEvents$ = this.cartEvents.asObservable();
dispatchCartEvent(event: CartEvent) {
this.cartEvents.next(event);
}
}
Use the service in product micro frontend:
// product-detail.component.ts from product micro frontend
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { SharedEventsService } from 'shared-lib';
import { ProductService } from '../product.service';
@Component({
selector: 'app-product-detail',
template: `
<div *ngIf="product">
<h2>{{product.name}}</h2>
<p>{{product.description}}</p>
<p>{{product.price | currency}}</p>
<button (click)="addToCart()">Add to Cart</button>
</div>
`
})
export class ProductDetailComponent implements OnInit {
product: any;
constructor(
private route: ActivatedRoute,
private productService: ProductService,
private sharedEvents: SharedEventsService
) {}
ngOnInit() {
const id = +this.route.snapshot.paramMap.get('id');
this.productService.getProduct(id)
.subscribe(product => this.product = product);
}
addToCart() {
this.sharedEvents.dispatchCartEvent({
type: 'ADD_TO_CART',
product: this.product
});
}
}
Listen for events in cart micro frontend:
// cart.component.ts from cart micro frontend
import { Component, OnInit, OnDestroy } from '@angular/core';
import { SharedEventsService } from 'shared-lib';
import { Subscription } from 'rxjs';
@Component({
selector: 'app-cart',
template: `
<h2>Your Cart</h2>
<div *ngIf="cartItems.length === 0">Cart is empty</div>
<div *ngFor="let item of cartItems">
<h3>{{item.name}}</h3>
<p>{{item.price | currency}}</p>
<button (click)="removeItem(item)">Remove</button>
</div>
<p>Total: {{total | currency}}</p>
`
})
export class CartComponent implements OnInit, OnDestroy {
cartItems = [];
total = 0;
private subscription: Subscription;
constructor(private sharedEvents: SharedEventsService) {}
ngOnInit() {
this.subscription = this.sharedEvents.cartEvents$.subscribe(event => {
if (event.type === 'ADD_TO_CART') {
this.cartItems.push(event.product);
this.calculateTotal();
}
});
}
removeItem(item) {
const index = this.cartItems.indexOf(item);
if (index > -1) {
this.cartItems.splice(index, 1);
this.calculateTotal();
this.sharedEvents.dispatchCartEvent({
type: 'REMOVE_FROM_CART',
product: item
});
}
}
calculateTotal() {
this.total = this.cartItems.reduce((sum, item) => sum + item.price, 0);
}
ngOnDestroy() {
this.subscription.unsubscribe();
}
}
Best Practices for Angular Micro Frontends
-
Define Clear Boundaries: Each micro frontend should have a well-defined business domain or feature set.
-
Avoid Shared UI Components: Duplicating components is often preferable to tight coupling between micro frontends.
-
Minimize Shared State: Excessive shared state can recreate the problems of monolithic applications.
-
Version Your Contracts: If micro frontends communicate through APIs or events, version these interfaces.
-
Consider UX Consistency: Use a shared design system to maintain visual consistency across micro frontends.
-
Setup CI/CD Pipelines: Each micro frontend should have its own CI/CD pipeline for independent deployment.
-
Monitor Performance: Watch out for duplicate dependencies or excessively large bundle sizes.
-
Implement Error Boundaries: Prevent errors in one micro frontend from breaking the entire application.
Common Challenges and Solutions
Challenge 1: Style Conflicts
Different micro frontends may include conflicting CSS styles.
Solution: Use CSS modules, Shadow DOM, or CSS-in-JS libraries to encapsulate styles within each micro frontend.
// Example with Shadow DOM in Angular Elements
@Component({
selector: 'app-product-card',
template: `
<div class="card">
<h3>{{name}}</h3>
<p>{{price | currency}}</p>
</div>
`,
styles: [`
.card { border: 1px solid #ddd; padding: 10px; }
`],
encapsulation: ViewEncapsulation.ShadowDom
})
export class ProductCardComponent {
@Input() name: string;
@Input() price: number;
}
Challenge 2: Authentication and Authorization
Sharing authentication state across micro frontends can be complex.
Solution: Use an authentication service with tokens stored in cookies or localStorage:
// auth.service.ts in shared library
import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class AuthService {
private currentUserSubject = new BehaviorSubject<any>(null);
currentUser$ = this.currentUserSubject.asObservable();
constructor() {
// Check localStorage on initialization
const userData = localStorage.getItem('currentUser');
if (userData) {
this.currentUserSubject.next(JSON.parse(userData));
}
}
login(username: string, password: string) {
// Implementation details omitted
// After successful login:
const user = { id: 1, username, token: 'jwt-token' };
localStorage.setItem('currentUser', JSON.stringify(user));
this.currentUserSubject.next(user);
}
logout() {
localStorage.removeItem('currentUser');
this.currentUserSubject.next(null);
}
}
Challenge 3: Shared Dependencies
Duplicate dependencies can increase the overall bundle size.
Solution: Use Module Federation's shared dependencies feature:
// webpack.config.js
new ModuleFederationPlugin({
// ...
shared: {
'@angular/core': { singleton: true, eager: true },
'@angular/common': { singleton: true, eager: true },
'@angular/router': { singleton: true, eager: true },
'rxjs': { singleton: true, eager: true }
}
})
Summary
Angular micro frontends provide a powerful way to scale large frontend applications by breaking them down into smaller, more manageable pieces. The key approaches we've explored include:
- Module Federation: The most integrated solution for Angular micro frontends
- Web Components: Using Angular Elements to create framework-agnostic components
- IFrames: For complete isolation between micro frontends
We've also covered communication strategies between micro frontends, best practices, and solutions to common challenges.
By implementing micro frontends, teams can work more independently, adopt new technologies incrementally, and scale development across multiple teams. However, it's important to be mindful of the added complexity and ensure that the benefits outweigh the costs for your specific project.
Additional Resources
- Angular Architects Module Federation Plugin
- Micro Frontends in Action (Book)
- Angular Elements Documentation
- Webpack 5 Module Federation
Exercise
Try building a simple dashboard application with these three micro frontends:
- A user management micro frontend
- A data visualization micro frontend
- A settings micro frontend
Implement cross-micro frontend communication so that when a user is selected in the user management micro frontend, the data visualization micro frontend updates to show that user's data.
Good luck with your micro frontend journey!
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)