Skip to main content

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.

Micro Frontend Architecture

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:

bash
ng add @angular-architects/module-federation

Then, configure the host application:

typescript
// 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:

typescript
// 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:

typescript
// 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:

typescript
// 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:

typescript
// 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:

html
<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:

html
<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

typescript
// 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:

typescript
// 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:

  1. Product Catalog micro frontend
  2. Shopping Cart micro frontend

Step 1: Set up the Host Application

bash
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:

typescript
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

bash
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:

typescript
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:

typescript
// 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

typescript
// 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:

typescript
// 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:

typescript
// 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:

typescript
// 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

  1. Define Clear Boundaries: Each micro frontend should have a well-defined business domain or feature set.

  2. Avoid Shared UI Components: Duplicating components is often preferable to tight coupling between micro frontends.

  3. Minimize Shared State: Excessive shared state can recreate the problems of monolithic applications.

  4. Version Your Contracts: If micro frontends communicate through APIs or events, version these interfaces.

  5. Consider UX Consistency: Use a shared design system to maintain visual consistency across micro frontends.

  6. Setup CI/CD Pipelines: Each micro frontend should have its own CI/CD pipeline for independent deployment.

  7. Monitor Performance: Watch out for duplicate dependencies or excessively large bundle sizes.

  8. 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.

typescript
// 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:

typescript
// 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:

typescript
// 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:

  1. Module Federation: The most integrated solution for Angular micro frontends
  2. Web Components: Using Angular Elements to create framework-agnostic components
  3. 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

Exercise

Try building a simple dashboard application with these three micro frontends:

  1. A user management micro frontend
  2. A data visualization micro frontend
  3. 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! :)