Welcome back, intrepid Angular architect! In this chapter, we’re going to level up our application design skills and tackle some truly advanced architectural patterns. As your applications grow in complexity, team size, and user expectations, traditional monolithic frontend approaches can become bottlenecks. We’ll explore strategies that empower independent teams, enable real-time user experiences, and provide unparalleled flexibility in deploying new features.
Our journey will cover the fascinating world of Microfrontends, allowing us to break down large applications into independently deployable units. We’ll then dive into WebSockets and Server-Sent Events (SSE), essential tools for building highly interactive, real-time user interfaces that respond instantly to server-side changes. Finally, we’ll master Runtime Feature Toggles, a powerful technique to decouple feature releases from code deployments, enabling safer rollouts and A/B testing.
By the end of this chapter, you’ll possess a deep understanding of how these advanced patterns solve critical production problems, and you’ll be equipped with the practical skills to implement them in your modern Angular standalone applications. Get ready to build truly scalable and resilient frontend systems!
Before we begin, ensure you’re comfortable with Angular standalone components, services, routing, and RxJS fundamentals. These concepts form the bedrock upon which we’ll build our advanced architectures.
Core Concepts
1. The Microfrontend Revolution
Building a large, single-page application (SPA) with a massive codebase can quickly become a “monolith” that slows down development, makes deployments risky, and limits technological choices. Imagine an application with dozens of features, each owned by a different team. A small change in one part could inadvertently affect another, leading to a complex and slow release process.
Why Microfrontends?
Microfrontends are an architectural style where a frontend application is decomposed into smaller, independently deployable units. Think of it as applying the microservices principle to the frontend.
Problem Solved:
- Independent Teams & Development: Each team can own, develop, and deploy their microfrontend independently, reducing coordination overhead and accelerating development cycles.
- Technology Agnostic: While we’re focusing on Angular, in a true microfrontend setup, different microfrontends could even be built with different frameworks (e.g., one with Angular, another with React, etc.), although this adds complexity.
- Scalability & Resilience: A bug in one microfrontend is less likely to bring down the entire application. You can scale development effort by adding more teams.
- Easier Upgrades: Upgrading a framework version for a small microfrontend is much less daunting than for a giant monolith.
Failures if Ignored:
- Deployment Paralysis: Fear of deploying due to the sheer size and interconnectedness of the monolith.
- Slow Feature Delivery: Teams constantly step on each other’s toes, leading to merge conflicts and integration hell.
- Technological Debt Accumulation: Difficulty adopting new technologies or upgrading existing ones due to the monolith’s inertia.
How Microfrontends Work (Native Federation)
For Angular, the most robust and modern approach to microfrontends is Native Federation. It leverages browser-native module loading capabilities, building upon the Webpack 5 Module Federation concept but optimized for modern Angular. It allows an “host” application to dynamically load and render “remote” applications or components at runtime.
Shared Singletons Across Microfrontends
A common challenge in microfrontend architectures is managing shared resources like authentication state, user profiles, or common utility services. We want these to behave as singletons across the entire user experience, even if different microfrontends are loaded. Native Federation facilitates this by allowing you to explicitly share dependencies.
- Problem Solved: Ensures a consistent user experience and avoids redundant API calls or state management logic across different parts of the application.
- Failures if Ignored: Users might have to log in again when navigating between microfrontends, or different parts of the UI might show conflicting data.
Safe Third-Party Embedding
Sometimes, you need to embed external widgets or applications that are not part of your internal microfrontend ecosystem. This could be a third-party chat widget, an analytics dashboard, or a legacy application.
- Problem Solved: Securely integrating external content without compromising the host application’s security or performance.
- Failures if Ignored: XSS vulnerabilities, styling conflicts, performance degradation, or broken functionality if the embedded content isn’t isolated.
A common approach involves using iframes for strong isolation, or carefully sandboxing web components if direct DOM integration is required. For our Angular context, we’ll focus on integrating other Angular microfrontends.
Let’s visualize a microfrontend setup:
2. Real-time Communication: WebSockets and SSE
In today’s dynamic web applications, users expect instant feedback and live updates without constantly refreshing their browser. Think of chat applications, live dashboards, stock tickers, or collaborative editing tools. Traditional HTTP request-response cycles are inefficient for these scenarios, often relying on “polling” (client repeatedly asking the server for updates), which wastes resources and introduces latency.
Why Real-time Communication?
Problem Solved:
- Instant Updates: Data changes on the server are pushed to the client immediately.
- Enhanced User Experience: Eliminates the need for manual refreshes or slow polling mechanisms.
- Reduced Server Load: Efficient use of network resources compared to constant polling.
Failures if Ignored:
- Stale Data: Users see outdated information.
- Poor User Experience: Frustration from waiting for updates or manually refreshing.
- Resource Inefficiency: High server load and network traffic from frequent polling.
WebSockets
WebSockets provide a full-duplex, persistent communication channel between a client (your Angular app) and a server over a single TCP connection. Once established, both client and server can send messages to each other at any time, without the overhead of HTTP headers for each message.
- When to use: Bidirectional communication (chat, gaming, collaborative tools), low-latency requirements.
- How it works: A standard HTTP request initiates a “handshake” to upgrade the connection to a WebSocket. Once upgraded, the connection stays open until explicitly closed.
Server-Sent Events (SSE)
SSE is a simpler, one-way mechanism where the server pushes updates to the client. Unlike WebSockets, it’s not full-duplex; the client cannot send messages back to the server over the same connection.
- When to use: Server-to-client updates only (news feeds, stock updates, live scoreboards, notifications).
- How it works: The client opens a persistent HTTP connection, and the server continuously sends data over it in a specific format. The browser automatically handles reconnection if the connection drops.
For Angular, we often leverage RxJS to manage these asynchronous streams of data, making connection management, message parsing, and error handling much more elegant.
3. Runtime Feature Toggles (Feature Flags)
Imagine you’ve just finished developing an exciting new feature. You want to deploy it to production, but you’re not quite ready for all users to see it yet. Or perhaps you want to test it with a small group of beta users. This is where feature toggles come in!
Why Feature Toggles?
Feature toggles (also known as feature flags) are a software development technique that allows you to turn features on or off during runtime without deploying new code.
Problem Solved:
- Decouple Deployment from Release: Deploy code to production without immediately exposing new features to users.
- A/B Testing & Gradual Rollouts: Test new features with a subset of users, gather feedback, and gradually roll out to everyone.
- Emergency Kill Switches: Quickly disable a problematic feature in production without a rollback.
- Trunk-Based Development: Teams can merge code to the main branch frequently, even for unfinished features, keeping branches short-lived.
Failures if Ignored:
- Risky Deployments: New features often require large, infrequent deployments, increasing the risk of bugs affecting all users.
- Long Release Cycles: Waiting for all features in a release to be complete before deploying.
- Poor User Feedback: Inability to test features with real users in a controlled manner.
How Feature Toggles Work
At its core, a feature toggle is a conditional statement (if (isFeatureEnabled('new-dashboard')) { ... }). The isFeatureEnabled function typically consults a FeatureFlagService that fetches the state of various flags from a central configuration source (e.g., a backend API, a configuration file, or a dedicated feature flagging service like LaunchDarkly or Split.io).
The decision of whether a feature is “on” or “off” can be based on various criteria:
- User ID
- User role
- Geographic location
- Percentage rollout
- Environment (development, staging, production)
Step-by-Step Implementation
We’ll use Angular v18+ (as of 2026-02-11) and its standalone architecture for all examples.
1. Setting up Microfrontends with Native Federation
Let’s imagine we have a shell application and a products microfrontend.
Prerequisites: You’ll need to install the Native Federation schematics.
# Ensure you have a recent Angular CLI (v18+)
npm install -g @angular/cli@latest
# Create a new workspace
ng new microfrontend-workspace --no-standalone --skip-git --skip-install
# Navigate into the workspace
cd microfrontend-workspace
# Add Native Federation to your workspace - this creates the initial host setup
# For Angular 18+, Native Federation is the recommended approach.
ng add @angular-architects/native-federation@latest --project shell --port 4200 --type host
Now, let’s create our first remote microfrontend:
ng generate application products --standalone --skip-git --skip-install
ng add @angular-architects/native-federation@latest --project products --port 4201 --type remote
This sets up the basic structure. You’ll have shell/src/app/app.config.ts, products/src/app/app.config.ts, and federation.config.ts files in both projects.
a. Configure the products Microfrontend (Remote)
Open projects/products/federation.config.ts. We need to expose a component.
// projects/products/federation.config.ts
import { withNativeFederation } from '@angular-architects/native-federation/webpack';
export default withNativeFederation({
name: 'products', // Unique name for this remote
exposes: {
// We expose a standalone component
'./Component': './projects/products/src/app/products.component.ts',
},
// Add any shared dependencies here.
// Native Federation tries to infer common dependencies, but explicit sharing is good.
shared: {
'@angular/core': { singleton: true, strictVersion: true, requiredVersion: 'auto' },
'@angular/common': { singleton: true, strictVersion: true, requiredVersion: 'auto' },
'@angular/router': { singleton: true, strictVersion: true, requiredVersion: 'auto' },
'rxjs': { singleton: true, strictVersion: true, requiredVersion: 'auto' },
// Add any other libraries you want to ensure are loaded only once
},
});
Now, let’s create a simple ProductsComponent in the products project.
// projects/products/src/app/products.component.ts
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
@Component({
standalone: true,
imports: [CommonModule],
selector: 'app-products',
template: `
<div class="products-container">
<h2>Products Microfrontend</h2>
<p>This content is loaded dynamically from the Products remote app!</p>
<ul>
<li>Product A</li>
<li>Product B</li>
<li>Product C</li>
</ul>
<button (click)="buyProduct()">Buy Something!</button>
</div>
`,
styles: `
.products-container {
border: 2px dashed #007bff;
padding: 15px;
margin: 10px;
background-color: #e6f2ff;
border-radius: 8px;
}
`,
})
export class ProductsComponent {
buyProduct() {
alert('Product bought from the remote app!');
}
}
b. Configure the shell Application (Host)
Open projects/shell/federation.config.ts. We need to tell the shell where to find the products remote.
// projects/shell/federation.config.ts
import { withNativeFederation } from '@angular-architects/native-federation/webpack';
export default withNativeFederation({
name: 'shell',
remotes: {
// Point to the products remote's entry point
products: 'http://localhost:4201/remoteEntry.json',
},
shared: {
// Ensure core Angular and RxJS libraries are shared as singletons
'@angular/core': { singleton: true, strictVersion: true, requiredVersion: 'auto' },
'@angular/common': { singleton: true, strictVersion: true, requiredVersion: 'auto' },
'@angular/router': { singleton: true, strictVersion: true, requiredVersion: 'auto' },
'rxjs': { singleton: true, strictVersion: true, requiredVersion: 'auto' },
},
});
Now, let’s modify the shell’s app.routes.ts to dynamically load the ProductsComponent.
// projects/shell/src/app/app.routes.ts
import { Routes } from '@angular/router';
import { loadRemoteModule } from '@angular-architects/native-federation';
import { HomeComponent } from './home/home.component'; // Create a simple home component
export const routes: Routes = [
{ path: '', component: HomeComponent, pathMatch: 'full' },
{
path: 'products',
loadComponent: () =>
loadRemoteModule('products', './Component').then((m) => m.ProductsComponent),
},
// ... other routes
];
And a simple HomeComponent for the shell:
// projects/shell/src/app/home/home.component.ts
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
@Component({
standalone: true,
imports: [CommonModule, RouterLink],
selector: 'app-home',
template: `
<div class="shell-home">
<h1>Welcome to the Angular Shell App!</h1>
<p>This is the main application acting as a host for microfrontends.</p>
<nav>
<a routerLink="/products">Go to Products Microfrontend</a>
</nav>
</div>
`,
styles: `
.shell-home {
padding: 20px;
text-align: center;
background-color: #f0f0f0;
border-bottom: 1px solid #ccc;
}
nav a {
margin: 0 10px;
padding: 8px 15px;
background-color: #007bff;
color: white;
text-decoration: none;
border-radius: 5px;
transition: background-color 0.3s ease;
}
nav a:hover {
background-color: #0056b3;
}
`,
})
export class HomeComponent {}
Finally, update projects/shell/src/app/app.component.ts to include RouterOutlet.
// projects/shell/src/app/app.component.ts
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterOutlet } from '@angular/router';
@Component({
selector: 'app-root',
standalone: true,
imports: [CommonModule, RouterOutlet],
template: `
<header>
<h1>My Awesome App (Shell)</h1>
</header>
<main>
<router-outlet></router-outlet>
</main>
`,
styles: `
header {
background-color: #333;
color: white;
padding: 15px;
text-align: center;
}
main {
padding: 20px;
}
`,
})
export class AppComponent {}
To Run:
- Open two terminal windows.
- In the first:
ng serve products --port 4201 - In the second:
ng serve shell --port 4200 - Navigate to
http://localhost:4200in your browser. Click the link to see the products microfrontend loaded!
c. Shared Singletons Example (Authentication Service)
Let’s create a shared AuthService that both the shell and the products microfrontend can use.
First, create a shared library in your workspace (optional, but good practice for shared code):
ng generate library shared-auth --standalone
Now, create AuthService inside projects/shared-auth/src/lib/auth.service.ts:
// projects/shared-auth/src/lib/auth.service.ts
import { Injectable, signal } from '@angular/core';
export interface User {
id: string;
name: string;
roles: string[];
}
@Injectable({
providedIn: 'root', // This ensures it's a true singleton when provided by Native Federation
})
export class AuthService {
private _currentUser = signal<User | null>(null);
currentUser = this._currentUser.asReadonly(); // Expose as a readonly signal
constructor() {
console.log('AuthService instance created!'); // To observe singleton behavior
// Simulate loading user from session storage
const storedUser = localStorage.getItem('currentUser');
if (storedUser) {
this._currentUser.set(JSON.parse(storedUser));
}
}
login(username: string, password: string): void {
// Simulate API call
if (username === 'test' && password === 'password') {
const user: User = { id: '123', name: 'Test User', roles: ['admin', 'viewer'] };
this._currentUser.set(user);
localStorage.setItem('currentUser', JSON.stringify(user));
console.log('User logged in:', user);
} else {
console.error('Login failed');
this._currentUser.set(null);
}
}
logout(): void {
this._currentUser.set(null);
localStorage.removeItem('currentUser');
console.log('User logged out');
}
isLoggedIn(): boolean {
return this._currentUser() !== null;
}
hasRole(role: string): boolean {
return this._currentUser()?.roles.includes(role) ?? false;
}
}
Now, expose this service from the products remote and consume it in both the shell and products applications.
1. Expose AuthService from products (or any other central remote/host)
It’s often best to expose shared services from one specific “utility” microfrontend or the host itself. For simplicity, let’s have products expose it.
Update projects/products/federation.config.ts exposures:
// projects/products/federation.config.ts (excerpt)
import { withNativeFederation } from '@angular-architects/native-federation/webpack';
export default withNativeFederation({
name: 'products',
exposes: {
'./Component': './projects/products/src/app/products.component.ts',
'./AuthService': './projects/shared-auth/src/lib/auth.service.ts', // Expose the service
},
// ... rest of the config
});
2. Consume AuthService in shell
Modify projects/shell/src/app/app.component.ts to use the AuthService.
// projects/shell/src/app/app.component.ts
import { Component, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterOutlet, RouterLink } from '@angular/router';
import { loadRemoteModule } from '@angular-architects/native-federation'; // Import loadRemoteModule
// We'll dynamically import AuthService from the products remote
// This is a type-only import for IntelliSense. The actual module is loaded at runtime.
import type { AuthService as ProductsAuthService } from '../../../shared-auth/src/lib/auth.service';
@Component({
selector: 'app-root',
standalone: true,
imports: [CommonModule, RouterOutlet, RouterLink], // Add RouterLink for navigation
template: `
<header>
<h1>My Awesome App (Shell)</h1>
<nav>
<a routerLink="/">Home</a> |
<a routerLink="/products">Products</a>
</nav>
<div class="auth-status">
@if (authService.isLoggedIn()) {
<span>Welcome, {{ authService.currentUser()?.name }}!</span>
<button (click)="logout()">Logout</button>
} @else {
<span>Not logged in</span>
<button (click)="login()">Login (test/password)</button>
}
</div>
</header>
<main>
<router-outlet></router-outlet>
</main>
`,
styles: `
header {
background-color: #333;
color: white;
padding: 15px;
text-align: center;
display: flex;
justify-content: space-between;
align-items: center;
}
header h1 { margin: 0; }
header nav a {
color: white;
margin: 0 10px;
text-decoration: none;
}
.auth-status {
display: flex;
align-items: center;
gap: 10px;
}
.auth-status button {
background-color: #28a745;
color: white;
border: none;
padding: 8px 12px;
border-radius: 5px;
cursor: pointer;
}
.auth-status button:hover {
background-color: #218838;
}
main {
padding: 20px;
}
`,
})
export class AppComponent {
// Use a temporary service placeholder until the actual AuthService is loaded
authService!: ProductsAuthService;
constructor() {
// Dynamically load the AuthService from the 'products' remote
loadRemoteModule('products', './AuthService').then((m) => {
// The service is providedIn: 'root', so Angular's DI will ensure a single instance.
// We need to manually get it from the injector if we want to use it here.
// For components, you'd just inject it normally after the remote is loaded.
// For this example, we'll assign it directly.
this.authService = inject(m.AuthService);
console.log('AuthService loaded in shell:', this.authService);
});
}
login() {
this.authService.login('test', 'password');
}
logout() {
this.authService.logout();
}
}
Important Note: The way AuthService is injected in AppComponent above (this.authService = inject(m.AuthService);) is a bit of a workaround for the root component. Typically, in child standalone components or services within the shell that need AuthService, you would simply inject(AuthService) after the remote modules containing AuthService have been loaded (e.g., via a route). Native Federation’s shared dependency mechanism ensures that providedIn: 'root' services are indeed singletons across the federated application.
3. Consume AuthService in products
Modify projects/products/src/app/products.component.ts.
// projects/products/src/app/products.component.ts
import { Component, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { AuthService } from '../../../../shared-auth/src/lib/auth.service'; // Import directly from the shared lib for type safety
@Component({
standalone: true,
imports: [CommonModule],
selector: 'app-products',
template: `
<div class="products-container">
<h2>Products Microfrontend</h2>
<p>This content is loaded dynamically from the Products remote app!</p>
@if (authService.isLoggedIn()) {
<p>Hello, {{ authService.currentUser()?.name }} from Products MF!</p>
<p>You have roles: {{ authService.currentUser()?.roles.join(', ') }}</p>
@if (authService.hasRole('admin')) {
<p class="admin-feature">Admin-only feature visible!</p>
}
} @else {
<p>Please log in from the Shell to see personalized product info.</p>
}
<ul>
<li>Product A</li>
<li>Product B</li>
<li>Product C</li>
</ul>
<button (click)="buyProduct()">Buy Something!</button>
</div>
`,
styles: `
.products-container {
border: 2px dashed #007bff;
padding: 15px;
margin: 10px;
background-color: #e6f2ff;
border-radius: 8px;
}
.admin-feature {
font-weight: bold;
color: #dc3545;
}
`,
})
export class ProductsComponent {
authService = inject(AuthService); // Inject the shared service
buyProduct() {
if (this.authService.isLoggedIn()) {
alert(`Product bought by ${this.authService.currentUser()?.name}!`);
} else {
alert('Please log in to buy products!');
}
}
}
Now, run both applications again. You’ll notice that logging in/out from the shell immediately updates the products microfrontend, demonstrating the shared singleton behavior of AuthService. The console.log('AuthService instance created!'); will only appear once, confirming a single instance.
2. Implementing WebSockets
Let’s create a simple real-time notification service using WebSockets.
# In your main application (e.g., shell or a new `core` library)
ng generate service websocket/notification
// projects/shell/src/app/websocket/notification.service.ts
import { Injectable, inject, OnDestroy } from '@angular/core';
import { webSocket, WebSocketSubject } from 'rxjs/webSocket';
import { Observable, timer, Subject, EMPTY, BehaviorSubject } from 'rxjs';
import {
retryWhen,
tap,
delayWhen,
switchMap,
catchError,
filter,
shareReplay,
} from 'rxjs/operators';
import { environment } from '../../environments/environment'; // Assuming you have an environment file
export interface AppNotification {
id: string;
message: string;
timestamp: string;
type: 'info' | 'warn' | 'error';
}
@Injectable({ providedIn: 'root' })
export class NotificationService implements OnDestroy {
private socket$!: WebSocketSubject<AppNotification>;
private messagesSubject = new Subject<AppNotification>();
private connectionStatusSubject = new BehaviorSubject<boolean>(false);
public notifications$: Observable<AppNotification>;
public connectionStatus$: Observable<boolean> = this.connectionStatusSubject.asObservable();
constructor() {
this.notifications$ = this.messagesSubject.asObservable().pipe(shareReplay(1));
this.connect();
}
private connect(): void {
if (this.socket$ && !this.socket$.closed) {
console.log('WebSocket already connected.');
return;
}
console.log('Attempting to connect to WebSocket...');
this.socket$ = webSocket<AppNotification>({
url: environment.websocketUrl, // e.g., 'ws://localhost:3000/notifications'
openObserver: {
next: () => {
console.log('WebSocket connection established!');
this.connectionStatusSubject.next(true);
},
},
closeObserver: {
next: (event: CloseEvent) => {
console.warn('WebSocket connection closed:', event);
this.connectionStatusSubject.next(false);
// Only retry if it's not a normal closure (e.g., code 1000)
if (event.code !== 1000) {
console.log('Retrying WebSocket connection...');
}
},
},
});
this.socket$
.pipe(
retryWhen((errors) =>
errors.pipe(
tap((err) => console.error('WebSocket error:', err)),
delayWhen(() => timer(5000)) // Retry after 5 seconds
)
),
catchError((error) => {
console.error('WebSocket caught fatal error:', error);
this.connectionStatusSubject.next(false);
return EMPTY; // Prevent stream from completing
})
)
.subscribe({
next: (msg) => this.messagesSubject.next(msg),
error: (err) => console.error('WebSocket stream error:', err), // This catches errors not handled by retryWhen
complete: () => {
console.log('WebSocket stream completed. Reconnecting...');
this.connectionStatusSubject.next(false);
// If the stream completes, it means the underlying socket closed and retryWhen didn't handle it
// This might indicate a server-side termination or a client-side explicit close.
// For robust apps, you might want to explicitly call connect() again here or let the retryWhen handle it.
// In this setup, retryWhen handles connection drops, so 'complete' might only happen on explicit close.
},
});
}
// Send a message (if your server supports it)
sendMessage(message: Partial<AppNotification>): void {
if (this.socket$ && !this.socket$.closed) {
this.socket$.next(message as AppNotification);
} else {
console.warn('WebSocket not connected. Cannot send message.');
}
}
disconnect(): void {
if (this.socket$ && !this.socket$.closed) {
this.socket$.complete(); // This will close the WebSocket
this.connectionStatusSubject.next(false);
console.log('WebSocket disconnected.');
}
}
ngOnDestroy(): void {
this.disconnect();
}
}
Explanation:
webSocketfromrxjs/webSocket: This is an RxJS operator that wraps the nativeWebSocketAPI, turning it into anObservablefor incoming messages and anObserverfor outgoing messages.openObserver/closeObserver: Allow us to react to connection status changes.retryWhen: This powerful RxJS operator handles reconnection logic. If an error occurs (e.g., connection drops), it willdelayWhenfor 5 seconds and then attempt to resubscribe, effectively retrying the connection.catchError: Catches any fatal errors thatretryWhenmight not handle, preventing the observable stream from dying.shareReplay(1): Ensures that multiple subscribers tonotifications$share the same WebSocket connection and receive the last emitted notification immediately upon subscription.environment.websocketUrl: Make sure to define this in yourprojects/shell/src/environments/environment.ts(or similar).// projects/shell/src/environments/environment.ts export const environment = { production: false, websocketUrl: 'ws://localhost:3000/notifications' // Replace with your WebSocket server URL };
To use the NotificationService in a component:
// projects/shell/src/app/app.component.ts (or any other component)
import { Component, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterOutlet, RouterLink } from '@angular/router';
import { NotificationService, AppNotification } from './websocket/notification.service';
import { Observable } from 'rxjs';
import { AsyncPipe } from '@angular/common'; // Don't forget AsyncPipe
// ... (existing imports and component definition)
@Component({
selector: 'app-root',
standalone: true,
imports: [CommonModule, RouterOutlet, RouterLink, AsyncPipe], // Add AsyncPipe
template: `
<header>
<!-- ... existing header content ... -->
<div class="notification-status">
<span [class.connected]="isConnected$ | async" [class.disconnected]="!(isConnected$ | async)">
WebSocket Status: {{ (isConnected$ | async) ? 'Connected' : 'Disconnected' }}
</span>
@if (latestNotification$ | async; as notification) {
<div [class]="'notification ' + notification.type">
{{ notification.message }} ({{ notification.timestamp | date:'shortTime' }})
</div>
}
</div>
</header>
<main>
<router-outlet></router-outlet>
</main>
`,
styles: `
/* ... existing styles ... */
.notification-status {
display: flex;
align-items: center;
gap: 15px;
font-size: 0.9em;
}
.connected { color: #28a745; font-weight: bold; }
.disconnected { color: #dc3545; }
.notification {
padding: 8px 12px;
border-radius: 5px;
margin-left: 10px;
color: white;
}
.notification.info { background-color: #007bff; }
.notification.warn { background-color: #ffc107; color: #333; }
.notification.error { background-color: #dc3545; }
`,
})
export class AppComponent {
// ... (existing properties like authService)
notificationService = inject(NotificationService);
isConnected$: Observable<boolean> = this.notificationService.connectionStatus$;
latestNotification$: Observable<AppNotification> = this.notificationService.notifications$;
constructor() {
// ... (existing constructor logic)
// You can subscribe here or use async pipe in template
this.notificationService.notifications$.subscribe(notification => {
console.log('Received notification:', notification);
});
}
// ... (existing login/logout methods)
}
To test this, you’ll need a simple WebSocket server. Here’s a quick Node.js example using ws:
// server.js (run with `node server.js`)
const WebSocket = require('ws');
const http = require('http');
const server = http.createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('WebSocket server is running\n');
});
const wss = new WebSocket.Server({ server });
let notificationId = 0;
wss.on('connection', ws => {
console.log('Client connected');
// Send a welcome message
ws.send(JSON.stringify({
id: `notif-${notificationId++}`,
message: 'Welcome to the real-time notification service!',
timestamp: new Date().toISOString(),
type: 'info'
}));
// Periodically send a new notification
const interval = setInterval(() => {
const messages = [
'New data available!',
'System update scheduled.',
'Warning: High CPU usage!',
'Error: Database connection lost!',
'Your order has shipped!'
];
const types = ['info', 'warn', 'error', 'info', 'info'];
const randomIndex = Math.floor(Math.random() * messages.length);
ws.send(JSON.stringify({
id: `notif-${notificationId++}`,
message: messages[randomIndex],
timestamp: new Date().toISOString(),
type: types[randomIndex]
}));
}, 5000); // Every 5 seconds
ws.on('message', message => {
console.log(`Received message from client: ${message}`);
// Echo message back or process it
ws.send(`Server received: ${message}`);
});
ws.on('close', () => {
console.log('Client disconnected');
clearInterval(interval);
});
ws.on('error', error => {
console.error('WebSocket error:', error);
});
});
server.listen(3000, () => {
console.log('HTTP and WebSocket server listening on port 3000');
});
Install ws and run: npm install ws && node server.js. Then, run your Angular app.
3. Implementing Feature Toggles
Let’s create a FeatureFlagService and demonstrate its use.
ng generate service feature-flag/feature-flag
// projects/shell/src/app/feature-flag/feature-flag.service.ts
import { Injectable, signal, effect } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, BehaviorSubject, of } from 'rxjs';
import { catchError, tap, shareReplay } from 'rxjs/operators';
import { environment } from '../../environments/environment'; // Assuming environment file
export interface FeatureFlags {
[key: string]: boolean;
}
@Injectable({
providedIn: 'root',
})
export class FeatureFlagService {
private _flags = signal<FeatureFlags>({});
public flags = this._flags.asReadonly();
private _flagsLoaded = new BehaviorSubject<boolean>(false);
public flagsLoaded$ = this._flagsLoaded.asObservable();
constructor(private http: HttpClient) {
// Load flags on service initialization
this.loadFeatureFlags().subscribe();
// Optional: Log flags when they change (for debugging)
effect(() => {
console.log('Current feature flags:', this.flags());
});
}
/**
* Loads feature flags from a backend API or local configuration.
* In a real app, this might fetch from a dedicated feature flagging service.
*/
private loadFeatureFlags(): Observable<FeatureFlags> {
// In production, this would be an HTTP call to a backend endpoint
// e.g., this.http.get<FeatureFlags>(environment.featureFlagApiUrl)
// For this example, we'll simulate a fetch with an observable.
return of({
'new-dashboard-enabled': true,
'beta-search-enabled': false,
'admin-panel-v2': true,
'experimental-checkout': false,
}).pipe(
tap((data) => {
this._flags.set(data);
this._flagsLoaded.next(true);
console.log('Feature flags loaded successfully.');
}),
catchError((error) => {
console.error('Failed to load feature flags, falling back to defaults:', error);
// Fallback to default flags if API fails
this._flags.set({
'new-dashboard-enabled': false,
'beta-search-enabled': false,
'admin-panel-v2': false,
'experimental-checkout': false,
});
this._flagsLoaded.next(true);
return of(this._flags());
}),
shareReplay(1) // Ensure flags are loaded only once and shared with multiple subscribers
);
}
/**
* Checks if a specific feature is enabled.
* @param flagName The name of the feature flag.
* @returns True if the feature is enabled, false otherwise.
*/
isFeatureEnabled(flagName: string): boolean {
return this.flags()[flagName] === true;
}
/**
* Refreshes feature flags from the source.
*/
refreshFlags(): Observable<FeatureFlags> {
this._flagsLoaded.next(false); // Indicate flags are being reloaded
return this.loadFeatureFlags();
}
}
Explanation:
_flags(signal): Stores the current state of all feature flags. Using a signal makes it reactive and efficient.HttpClient: In a real application, flags would be fetched from a backend service. Here,of()simulates this.loadFeatureFlags(): Fetches flags and updates the_flagssignal.shareReplay(1)is crucial to prevent multiple HTTP requests if multiple components try to access flags simultaneously.isFeatureEnabled(): A simple method to check a flag’s state.refreshFlags(): Allows dynamic updates to flags without a full page reload.
To use the FeatureFlagService in a component:
First, ensure HttpClientModule is provided in your app.config.ts.
// projects/shell/src/app/app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient } from '@angular/common/http'; // Import this
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
provideHttpClient() // Provide HttpClient
]
};
Now, in projects/shell/src/app/app.component.ts (or any other component):
// projects/shell/src/app/app.component.ts
import { Component, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterOutlet, RouterLink } from '@angular/router';
import { NotificationService, AppNotification } from './websocket/notification.service';
import { FeatureFlagService } from './feature-flag/feature-flag.service'; // Import
import { Observable } from 'rxjs';
import { AsyncPipe } from '@angular/common';
// ... (existing imports)
@Component({
selector: 'app-root',
standalone: true,
imports: [CommonModule, RouterOutlet, RouterLink, AsyncPipe],
template: `
<header>
<!-- ... existing header content ... -->
<div class="feature-status">
<button (click)="featureFlagService.refreshFlags()">Refresh Flags</button>
@if (featureFlagService.isFeatureEnabled('new-dashboard-enabled')) {
<span class="feature-on">New Dashboard Enabled!</span>
} @else {
<span class="feature-off">Old Dashboard Active.</span>
}
</div>
</header>
<main>
<router-outlet></router-outlet>
</main>
`,
styles: `
/* ... existing styles ... */
.feature-status {
display: flex;
align-items: center;
gap: 15px;
font-size: 0.9em;
}
.feature-on { color: #28a745; font-weight: bold; }
.feature-off { color: #ffc107; font-weight: bold; }
.feature-status button {
background-color: #6c757d;
color: white;
border: none;
padding: 8px 12px;
border-radius: 5px;
cursor: pointer;
}
.feature-status button:hover {
background-color: #5a6268;
}
`,
})
export class AppComponent {
// ... (existing properties)
featureFlagService = inject(FeatureFlagService);
// ... (existing constructor and methods)
}
Now, if you were to change new-dashboard-enabled to false in loadFeatureFlags() simulation or via an actual backend, the UI would dynamically update after refreshing the flags.
Mini-Challenge: Resilient WebSocket with Exponential Backoff
You’ve built a basic WebSocket service with a fixed retry delay. For robust production applications, it’s better to implement an exponential backoff strategy. This means the delay between retries increases with each failed attempt, preventing your client from hammering the server during an outage.
Challenge: Modify the NotificationService’s retryWhen logic to implement an exponential backoff strategy.
Requirements:
- The first retry should be after 1 second.
- Subsequent retries should double the delay (e.g., 1s, 2s, 4s, 8s…).
- Implement a maximum retry delay (e.g., 30 seconds) to prevent excessively long waits.
- Limit the total number of retry attempts (e.g., 10 attempts) before giving up.
Hint: You’ll need to keep track of the retry attempt count.
retryWhenreceives an observable of errors; you can usescanto accumulate the attempt count andziporconcatMapwithtimerfor the delays. Remember tothrowErrorif the maximum attempts are reached.What to observe/learn: How to build highly resilient real-time connections that gracefully handle network instability and server outages, improving the overall user experience and reducing server load during recovery.
Common Pitfalls & Troubleshooting
Microfrontends: Version Mismatches and Shared Dependencies:
- Pitfall: Different microfrontends (or the host) using different, incompatible versions of shared libraries (e.g.,
@angular/core,rxjs, a common UI library). This can lead to runtime errors, unexpected behavior, or larger bundle sizes if libraries are duplicated. - Troubleshooting:
- Carefully define
shareddependencies infederation.config.ts, especiallystrictVersion: trueandrequiredVersion: 'auto'(or a specific version range). - Use
npm list <package-name>in each project to verify installed versions. - Check the browser’s network tab for duplicate library loads.
- For complex cases, use a tool like
webpack-bundle-analyzerto visualize shared module usage.
- Carefully define
- Pitfall: Different microfrontends (or the host) using different, incompatible versions of shared libraries (e.g.,
WebSockets: Unhandled Disconnections and Message Parsing Errors:
- Pitfall: Clients not gracefully handling server-side disconnections or receiving malformed messages, leading to broken UI or crashes.
- Troubleshooting:
- Disconnections: Ensure your
retryWhenlogic is robust, includes exponential backoff, and has a maximum number of retries. Provide clear UI feedback (e.g., “Connection Lost, Retrying…”). - Message Parsing: Always wrap
JSON.parse()in atry...catchblock when processing incoming WebSocket messages. Log the raw message if parsing fails to debug the server-side payload. - Use tools like browser developer tools (Network tab -> WS) to inspect WebSocket frames.
- Disconnections: Ensure your
Feature Toggles: Flag Sprawl and Technical Debt:
- Pitfall: Accumulating too many feature flags over time, leading to a complex codebase with many
ifstatements, making it hard to understand which features are active and increasing testing matrix complexity. Flags for temporary features (e.g., A/B tests) are often left in indefinitely. - Troubleshooting:
- Lifecycle Management: Implement a clear lifecycle for feature flags (e.g., temporary, permanent). Regularly review and “clean up” (remove) flags that are no longer needed.
- Naming Conventions: Use clear, descriptive names for flags.
- Configuration Management: Store flags in a centralized, easily manageable system (e.g., a dedicated service, a CMS, or a simple JSON file).
- Automated Testing: Ensure your tests cover both “on” and “off” states for critical features controlled by flags.
- Pitfall: Accumulating too many feature flags over time, leading to a complex codebase with many
Summary
Phew, we covered a lot in this chapter! You’ve taken a significant leap into advanced Angular architecture, equipping yourself with powerful tools for building large-scale, resilient, and dynamic applications.
Here are the key takeaways:
- Microfrontends (Native Federation):
- Decompose large frontends into independently deployable units, fostering team autonomy and scalability.
- Utilize Native Federation for Angular 18+ to dynamically load remote components and applications.
- Manage shared singletons (like an
AuthService) effectively across microfrontends to maintain a consistent user experience.
- Real-time Communication (WebSockets & SSE):
- Leverage WebSockets for full-duplex, low-latency communication (e.g., chat, live updates).
- Use
rxjs/webSocketto integrate WebSockets reactively in Angular, handling connection, messages, and errors. - Implement robust reconnection strategies (like exponential backoff) for resilient real-time experiences.
- Understand Server-Sent Events (SSE) as a simpler alternative for server-to-client push scenarios.
- Runtime Feature Toggles:
- Decouple feature deployment from release, enabling safer rollouts, A/B testing, and emergency kill switches.
- Implement a
FeatureFlagServiceto manage flag states, often fetched from a backend. - Use flags in templates (
@if) and services to conditionally enable/disable features at runtime. - Practice good flag lifecycle management to prevent “flag sprawl.”
By mastering these advanced architectural patterns, you’re now capable of designing and implementing Angular applications that can meet the demands of enterprise-level complexity, deliver exceptional user experiences, and empower your development teams. Keep experimenting, keep building, and keep pushing the boundaries of what’s possible with Angular!
References
- Angular Architects - Native Federation: https://github.com/angular-architects/native-federation
- Angular Documentation (Standalone Components): https://angular.dev/guide/standalone-components
- RxJS Documentation (webSocket): https://rxjs.dev/api/webSocket/webSocket
- Mozilla Developer Network (WebSockets API): https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API
- Mozilla Developer Network (Server-Sent Events): https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events
- Martin Fowler - Feature Toggles: https://martinfowler.com/articles/feature-toggles.html
This page is AI-assisted and reviewed. It references official documentation and recognized resources where relevant.