Advanced Structural Design Patterns in Angular

As a Technical Architect specializing in UI development, understanding and implementing design patterns is crucial for building scalable and maintainable applications. Structural design patterns are particularly important as they define how to organize and assemble objects and classes into larger structures. In this article, we’ll explore some of the most commonly used Structural Design Patterns in Angular, along with practical examples.
1. Adapter Pattern
The Adapter pattern allows your Angular application to connect to incompatible interfaces, especially useful when integrating with APIs that provide data in unexpected formats.
Example: API Integration with an Adapter
When working with a weather API that returns data not matching your existing structure, the Adapter pattern can bridge this gap.
// Weather API response structure
interface WeatherApiResponse {
temp: number;
description: string;
}
// Expected format in Angular
interface Weather {
temperature: number;
summary: string;
}
// Adapter
class WeatherAdapter {
constructor(private apiResponse: WeatherApiResponse) {}
adapt(): Weather {
return {
temperature: this.apiResponse.temp,
summary: this.apiResponse.description,
};
}
}
// Angular service
@Injectable({
providedIn: 'root',
})
export class WeatherService {
constructor(private http: HttpClient) {}
getWeather(): Observable<Weather> {
return this.http.get<WeatherApiResponse>('api/weather').pipe(
map(response => new WeatherAdapter(response).adapt())
);
}
}
// Component utilizing the service
@Component({
selector: 'app-weather',
template: `<div>User Weather: {{weather?.summary}} at {{weather?.temperature}}°C</div>`,
})
export class WeatherComponent implements OnInit {
weather!: Weather;
constructor(private weatherService: WeatherService) {}
ngOnInit() {
this.weatherService.getWeather().subscribe(data => this.weather = data);
}
}2. Composite Pattern
The Composite pattern is excellent for creating tree structures, making it especially useful for designing reusable, nested components in Angular applications.
Example: Nested Components with Composite Pattern
Here’s how to build a dashboard featuring various widgets.
// Base component interface
abstract class DashboardWidget {
abstract render(): string;
}
// Leaf component
class SimpleWidget extends DashboardWidget {
constructor(private name: string) {
super();
}
render() {
return `<div>${this.name} Widget</div>`;
}
}
// Composite component
class WidgetGroup extends DashboardWidget {
private widgets: DashboardWidget[] = [];
add(widget: DashboardWidget) {
this.widgets.push(widget);
}
render() {
const items = this.widgets.map(widget => widget.render()).join('');
return `<div class="widget-group">${items}</div>`;
}
}
// Angular component
@Component({
selector: 'app-dashboard',
template: `<div [innerHTML]="dashboard.render()"></div>`,
})
export class DashboardComponent {
private dashboard: WidgetGroup;
constructor() {
this.dashboard = new WidgetGroup();
this.dashboard.add(new SimpleWidget('Weather'));
this.dashboard.add(new SimpleWidget('Stock'));
const nestedGroup = new WidgetGroup();
nestedGroup.add(new SimpleWidget('News'));
this.dashboard.add(nestedGroup);
}
render() {
return this.dashboard.render();
}
}3. Decorator Pattern
The Decorator pattern allows for dynamic augmentation of existing classes. It’s widely used in Angular with decorators but can also be implemented custom.
Example: Custom Logging Decorator
Here’s a scenario where you want to log method executions in a service.
function LogExecution(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
descriptor.value = function (…args: any[]) {
console.log(`Executing: ${propertyKey}, Args:`, args);
return originalMethod.apply(this, args);
};
}
@Injectable({
providedIn: 'root',
})
export class UserService {
@LogExecution
createUser(user: any) {
// Logic to create a user
}
@LogExecution
updateUser(userId: string, updatedData: any) {
// Logic to update user
}
}4. Facade Pattern
The Facade pattern simplifies interactions with complex subsystems by providing a unified interface.
Example: Data Service Facade
If you have multiple services for user profiles, settings, and notifications, a facade can streamline their usage.
@Injectable({
providedIn: 'root',
})
export class UserProfileService {
getUserProfile(userId: string) {
// Logic to fetch user profile
}
}
@Injectable({
providedIn: 'root',
})
export class UserSettingsService {
getUserSettings(userId: string) {
// Logic to fetch user settings
}
}
@Injectable({
providedIn: 'root',
})
export class UserFacade {
constructor(
private userProfileService: UserProfileService,
private userSettingsService: UserSettingsService
) {}
getUserData(userId: string) {
return forkJoin({
profile: this.userProfileService.getUserProfile(userId),
settings: this.userSettingsService.getUserSettings(userId)
});
}
}
// Usage in a component
@Component({
selector: 'app-user',
template: `<div>User Data: {{userData | json}}</div>`,
})
export class UserComponent implements OnInit {
userData!: any;
constructor(private userFacade: UserFacade) {}
ngOnInit() {
this.userFacade.getUserData('123').subscribe(data => this.userData = data);
}
}5. Proxy Pattern
The Proxy pattern acts as an intermediary for data fetching, allowing for features such as caching and authorization checks.
Example: Fetching User Data with Caching Using Observables
This example illustrates how to manage user data requests with caching and authorization.
import { Injectable } from '@angular/core';
import { Observable, of, throwError } from 'rxjs';
import { tap, delay } from 'rxjs/operators';
// Mock API call function to simulate an external service
const fetchUserDataFromApi = (userId: string): Observable<any> => {
console.log(`Fetching data for user ${userId} from API…`);
return of({ id: userId, name: "John Doe" }).pipe(delay(2000)); // Simulate network delay
};
// Proxy class
class UserProxy {
private cache: { [key: string]: any } = {};
constructor(private userId: string) {}
getUserData(): Observable<any> {
if (this.cache[this.userId]) {
console.log('Returning user data from cache.');
return of(this.cache[this.userId]); // Return cached data
}
if (!this.isAuthorized()) {
return throwError(new Error('Unauthorized access'));
}
return fetchUserDataFromApi(this.userId).pipe(
tap(userData => this.cache[this.userId] = userData) // Cache the result
);
}
private isAuthorized(): boolean {
// Implement your authorization logic here
return true; // For simplicity, allow access
}
}
// Angular Service
@Injectable({
providedIn: 'root',
})
export class UserService {
private userProxy: UserProxy;
constructor() {
this.userProxy = new UserProxy('123');
}
getUserData(): Observable<any> {
return this.userProxy.getUserData();
}
}
// Using in a component
@Component({
selector: 'app-user',
template: `<div *ngIf="user">User: {{user.name}}</div>`,
})
export class UserComponent implements OnInit {
user: any;
constructor(private userService: UserService) {}
ngOnInit() {
// Fetch user data
this.userService.getUserData().subscribe(
(data) => {
this.user = data;
},
(error) => {
console.error(error);
});
// Demonstrate caching
this.userService.getUserData().subscribe(
(data) => {
console.log('Received cached data:', data);
},
(error) => {
console.error(error);
});
}
}Conclusion
Implementing structural design patterns such as Adapter, Composite, Decorator, Facade, and Proxy in Angular applications enhances code organization, maintainability, and scalability. Understanding these patterns enables developers to create robust architectures that can adapt to increasing complexity in real-world applications. By leveraging these principles, you can optimize your development process and build more effective and efficient Angular applications.






