Initial commit
This commit is contained in:
693
skills/enterprise-patterns/SKILL.md
Normal file
693
skills/enterprise-patterns/SKILL.md
Normal file
@@ -0,0 +1,693 @@
|
||||
# Enterprise Angular Patterns
|
||||
|
||||
Proven architectural patterns for building scalable Angular applications in enterprise environments with teams of 5-100+ developers.
|
||||
|
||||
---
|
||||
|
||||
## Core Principles
|
||||
|
||||
1. **Separation of Concerns** - Each piece of code has one responsibility
|
||||
2. **Single Source of Truth** - State lives in one place
|
||||
3. **Consistency** - Follow patterns religiously
|
||||
4. **Scalability** - Design for 10x growth
|
||||
5. **Maintainability** - Code should be easy to change
|
||||
|
||||
---
|
||||
|
||||
## Pattern 1: Core-Shared-Features Structure
|
||||
|
||||
### Overview
|
||||
Organize code into three main categories based on scope and reusability.
|
||||
|
||||
### The Three Folders
|
||||
|
||||
```
|
||||
src/app/
|
||||
├── core/ # App-wide singletons (loaded once)
|
||||
├── shared/ # Reusable components/utilities
|
||||
└── features/ # Feature modules (lazy loaded)
|
||||
```
|
||||
|
||||
### Core Module Rules
|
||||
|
||||
**What belongs in core:**
|
||||
- ✅ Singleton services (AuthService, ApiService, CacheService)
|
||||
- ✅ HTTP interceptors (auth, error handling, retry)
|
||||
- ✅ Route guards (authentication, authorization)
|
||||
- ✅ Global error handlers
|
||||
- ✅ App-wide models and interfaces
|
||||
- ✅ Constants and configuration
|
||||
|
||||
**What does NOT belong:**
|
||||
- ❌ UI components
|
||||
- ❌ Feature-specific services
|
||||
- ❌ Reusable utilities (those go in shared)
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
// core/services/auth.service.ts
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AuthService {
|
||||
private currentUser$ = new BehaviorSubject<User | null>(null);
|
||||
|
||||
login(credentials: Credentials): Observable<User> {
|
||||
return this.http.post<User>('/api/auth/login', credentials).pipe(
|
||||
tap(user => this.currentUser$.next(user))
|
||||
);
|
||||
}
|
||||
|
||||
getCurrentUser(): Observable<User | null> {
|
||||
return this.currentUser$.asObservable();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Shared Module Rules
|
||||
|
||||
**What belongs in shared:**
|
||||
- ✅ Dumb/presentational components (buttons, cards, modals)
|
||||
- ✅ Custom directives (tooltips, permissions, auto-focus)
|
||||
- ✅ Custom pipes (formatting, filtering)
|
||||
- ✅ Utility functions (date helpers, validators)
|
||||
- ✅ Common interfaces used across features
|
||||
|
||||
**What does NOT belong:**
|
||||
- ❌ Business logic
|
||||
- ❌ HTTP calls
|
||||
- ❌ Feature-specific components
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
// shared/components/data-table/data-table.component.ts
|
||||
@Component({
|
||||
selector: 'app-data-table',
|
||||
standalone: true,
|
||||
template: `
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
@for (column of columns(); track column.key) {
|
||||
<th>{{ column.label }}</th>
|
||||
}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of data(); track row.id) {
|
||||
<tr>
|
||||
@for (column of columns(); track column.key) {
|
||||
<td>{{ row[column.key] }}</td>
|
||||
}
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
`
|
||||
})
|
||||
export class DataTableComponent {
|
||||
columns = input.required<Column[]>();
|
||||
data = input.required<any[]>();
|
||||
}
|
||||
```
|
||||
|
||||
### Features Module Rules
|
||||
|
||||
**What belongs in features:**
|
||||
- ✅ Feature-specific components (smart + dumb)
|
||||
- ✅ Feature-specific services
|
||||
- ✅ Feature-specific models
|
||||
- ✅ Feature routing configuration
|
||||
|
||||
**Structure:**
|
||||
```
|
||||
features/
|
||||
└── products/
|
||||
├── components/ # Feature components
|
||||
│ ├── product-list/
|
||||
│ ├── product-detail/
|
||||
│ └── product-form/
|
||||
├── services/ # Feature services
|
||||
│ └── product.service.ts
|
||||
├── models/ # Feature models
|
||||
│ └── product.interface.ts
|
||||
├── products.routes.ts # Feature routes
|
||||
└── products.component.ts # Container component
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pattern 2: Smart and Dumb Components
|
||||
|
||||
### Overview
|
||||
Separate components that manage data (smart) from components that display data (dumb).
|
||||
|
||||
### Smart Components (Containers)
|
||||
|
||||
**Characteristics:**
|
||||
- Communicate with services
|
||||
- Manage state
|
||||
- Handle business logic
|
||||
- Usually top-level feature components
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
// features/products/product-list.component.ts
|
||||
@Component({
|
||||
selector: 'app-product-list',
|
||||
template: `
|
||||
<app-search-bar (search)="handleSearch($event)" />
|
||||
|
||||
@if (loading()) {
|
||||
<app-loading-spinner />
|
||||
} @else if (error()) {
|
||||
<app-error-message [error]="error()" />
|
||||
} @else {
|
||||
@for (product of products(); track product.id) {
|
||||
<app-product-card
|
||||
[product]="product"
|
||||
(edit)="handleEdit($event)"
|
||||
(delete)="handleDelete($event)"
|
||||
/>
|
||||
}
|
||||
}
|
||||
`
|
||||
})
|
||||
export class ProductListComponent {
|
||||
private productService = inject(ProductService);
|
||||
|
||||
products = signal<Product[]>([]);
|
||||
loading = signal(false);
|
||||
error = signal<string | null>(null);
|
||||
|
||||
ngOnInit() {
|
||||
this.loadProducts();
|
||||
}
|
||||
|
||||
loadProducts() {
|
||||
this.loading.set(true);
|
||||
this.productService.getProducts().pipe(
|
||||
takeUntilDestroyed()
|
||||
).subscribe({
|
||||
next: products => {
|
||||
this.products.set(products);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: err => {
|
||||
this.error.set(err.message);
|
||||
this.loading.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
handleEdit(id: string) {
|
||||
this.router.navigate(['/products', id, 'edit']);
|
||||
}
|
||||
|
||||
handleDelete(id: string) {
|
||||
if (confirm('Delete this product?')) {
|
||||
this.productService.delete(id).subscribe();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Dumb Components (Presentational)
|
||||
|
||||
**Characteristics:**
|
||||
- Receive data via @Input or input()
|
||||
- Emit events via @Output or output()
|
||||
- No service dependencies
|
||||
- Highly reusable
|
||||
- Easy to test
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
// shared/components/product-card.component.ts
|
||||
@Component({
|
||||
selector: 'app-product-card',
|
||||
standalone: true,
|
||||
imports: [CurrencyPipe],
|
||||
template: `
|
||||
<div class="card">
|
||||
<img [src]="product().image" [alt]="product().name" />
|
||||
<h3>{{ product().name }}</h3>
|
||||
<p>{{ product().price | currency }}</p>
|
||||
<div class="actions">
|
||||
<button (click)="edit.emit(product().id)">Edit</button>
|
||||
<button (click)="delete.emit(product().id)">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
export class ProductCardComponent {
|
||||
product = input.required<Product>();
|
||||
edit = output<string>();
|
||||
delete = output<string>();
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pattern 3: Service Layer Architecture
|
||||
|
||||
### Overview
|
||||
Organize services by responsibility: data access, business logic, and state management.
|
||||
|
||||
### Data Services
|
||||
|
||||
**Purpose:** HTTP communication only
|
||||
|
||||
```typescript
|
||||
// core/services/api.service.ts
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ApiService {
|
||||
private http = inject(HttpClient);
|
||||
private baseUrl = environment.apiUrl;
|
||||
|
||||
get<T>(endpoint: string): Observable<T> {
|
||||
return this.http.get<T>(`${this.baseUrl}/${endpoint}`);
|
||||
}
|
||||
|
||||
post<T>(endpoint: string, data: any): Observable<T> {
|
||||
return this.http.post<T>(`${this.baseUrl}/${endpoint}`, data);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Business Services
|
||||
|
||||
**Purpose:** Business logic and domain operations
|
||||
|
||||
```typescript
|
||||
// features/products/services/product.service.ts
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ProductService {
|
||||
private api = inject(ApiService);
|
||||
|
||||
getProducts(): Observable<Product[]> {
|
||||
return this.api.get<Product[]>('products').pipe(
|
||||
map(products => this.enrichProducts(products))
|
||||
);
|
||||
}
|
||||
|
||||
private enrichProducts(products: Product[]): Product[] {
|
||||
return products.map(p => ({
|
||||
...p,
|
||||
displayPrice: this.formatPrice(p.price),
|
||||
inStock: p.quantity > 0
|
||||
}));
|
||||
}
|
||||
|
||||
private formatPrice(price: number): string {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD'
|
||||
}).format(price);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### State Services
|
||||
|
||||
**Purpose:** Manage application state
|
||||
|
||||
```typescript
|
||||
// features/cart/services/cart-state.service.ts
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class CartStateService {
|
||||
private itemsSubject = new BehaviorSubject<CartItem[]>([]);
|
||||
|
||||
// Public observable
|
||||
items$ = this.itemsSubject.asObservable();
|
||||
|
||||
// Computed values
|
||||
total$ = this.items$.pipe(
|
||||
map(items => items.reduce((sum, item) => sum + item.price * item.quantity, 0))
|
||||
);
|
||||
|
||||
itemCount$ = this.items$.pipe(
|
||||
map(items => items.reduce((count, item) => count + item.quantity, 0))
|
||||
);
|
||||
|
||||
addItem(item: CartItem) {
|
||||
const current = this.itemsSubject.value;
|
||||
this.itemsSubject.next([...current, item]);
|
||||
}
|
||||
|
||||
removeItem(id: string) {
|
||||
const current = this.itemsSubject.value;
|
||||
this.itemsSubject.next(current.filter(item => item.id !== id));
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.itemsSubject.next([]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pattern 4: Facade Pattern
|
||||
|
||||
### Overview
|
||||
Create a single entry point for complex subsystems.
|
||||
|
||||
### Use Case
|
||||
When a feature has multiple related services that components need to interact with.
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
// features/checkout/services/checkout.facade.ts
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class CheckoutFacade {
|
||||
private cartService = inject(CartService);
|
||||
private paymentService = inject(PaymentService);
|
||||
private shippingService = inject(ShippingService);
|
||||
private orderService = inject(OrderService);
|
||||
|
||||
// Expose simplified API
|
||||
cart$ = this.cartService.items$;
|
||||
total$ = this.cartService.total$;
|
||||
shippingMethods$ = this.shippingService.getMethods();
|
||||
|
||||
processCheckout(data: CheckoutData): Observable<Order> {
|
||||
return this.validateCart().pipe(
|
||||
switchMap(() => this.calculateShipping(data.shippingMethod)),
|
||||
switchMap(shipping => this.processPayment(data.payment, shipping)),
|
||||
switchMap(payment => this.createOrder({ ...data, payment })),
|
||||
tap(() => this.cartService.clear())
|
||||
);
|
||||
}
|
||||
|
||||
private validateCart(): Observable<boolean> {
|
||||
return this.cart$.pipe(
|
||||
take(1),
|
||||
map(items => items.length > 0),
|
||||
tap(valid => { if (!valid) throw new Error('Cart is empty'); })
|
||||
);
|
||||
}
|
||||
|
||||
private calculateShipping(method: string): Observable<number> {
|
||||
return this.shippingService.calculate(method);
|
||||
}
|
||||
|
||||
private processPayment(payment: PaymentInfo, shipping: number): Observable<PaymentResult> {
|
||||
return this.total$.pipe(
|
||||
take(1),
|
||||
switchMap(total => this.paymentService.charge({
|
||||
...payment,
|
||||
amount: total + shipping
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
private createOrder(data: OrderData): Observable<Order> {
|
||||
return this.orderService.create(data);
|
||||
}
|
||||
}
|
||||
|
||||
// Component uses facade instead of multiple services
|
||||
@Component({...})
|
||||
export class CheckoutComponent {
|
||||
private facade = inject(CheckoutFacade);
|
||||
|
||||
cart$ = this.facade.cart$;
|
||||
total$ = this.facade.total$;
|
||||
|
||||
checkout(data: CheckoutData) {
|
||||
this.facade.processCheckout(data).subscribe({
|
||||
next: order => this.router.navigate(['/order-confirmation', order.id]),
|
||||
error: err => this.showError(err)
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pattern 5: Error Handling Strategy
|
||||
|
||||
### Global Error Handler
|
||||
|
||||
```typescript
|
||||
// core/handlers/global-error.handler.ts
|
||||
@Injectable()
|
||||
export class GlobalErrorHandler implements ErrorHandler {
|
||||
private logger = inject(LoggerService);
|
||||
private notification = inject(NotificationService);
|
||||
|
||||
handleError(error: Error | HttpErrorResponse) {
|
||||
if (error instanceof HttpErrorResponse) {
|
||||
// Server error
|
||||
this.handleHttpError(error);
|
||||
} else {
|
||||
// Client error
|
||||
this.handleClientError(error);
|
||||
}
|
||||
}
|
||||
|
||||
private handleHttpError(error: HttpErrorResponse) {
|
||||
const message = this.getErrorMessage(error);
|
||||
this.notification.showError(message);
|
||||
this.logger.error('HTTP Error', { error, url: error.url });
|
||||
}
|
||||
|
||||
private handleClientError(error: Error) {
|
||||
this.notification.showError('An unexpected error occurred');
|
||||
this.logger.error('Client Error', { error, stack: error.stack });
|
||||
}
|
||||
|
||||
private getErrorMessage(error: HttpErrorResponse): string {
|
||||
if (error.status === 0) {
|
||||
return 'No internet connection';
|
||||
} else if (error.status === 401) {
|
||||
return 'Session expired. Please login again.';
|
||||
} else if (error.status === 403) {
|
||||
return 'You do not have permission to perform this action';
|
||||
} else if (error.status >= 500) {
|
||||
return 'Server error. Please try again later.';
|
||||
}
|
||||
return error.error?.message || 'An error occurred';
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### HTTP Error Interceptor
|
||||
|
||||
```typescript
|
||||
// core/interceptors/error.interceptor.ts
|
||||
export const errorInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
return next(req).pipe(
|
||||
catchError((error: HttpErrorResponse) => {
|
||||
if (error.status === 401) {
|
||||
// Redirect to login
|
||||
inject(Router).navigate(['/login']);
|
||||
}
|
||||
return throwError(() => error);
|
||||
})
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pattern 6: Feature Flags
|
||||
|
||||
### Overview
|
||||
Control feature visibility without deploying new code.
|
||||
|
||||
```typescript
|
||||
// core/services/feature-flag.service.ts
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class FeatureFlagService {
|
||||
private flags = signal<Record<string, boolean>>({
|
||||
'new-dashboard': false,
|
||||
'beta-checkout': true,
|
||||
'admin-analytics': false
|
||||
});
|
||||
|
||||
isEnabled(feature: string): boolean {
|
||||
return this.flags()[feature] ?? false;
|
||||
}
|
||||
|
||||
enable(feature: string) {
|
||||
this.flags.update(flags => ({ ...flags, [feature]: true }));
|
||||
}
|
||||
|
||||
disable(feature: string) {
|
||||
this.flags.update(flags => ({ ...flags, [feature]: false }));
|
||||
}
|
||||
}
|
||||
|
||||
// Usage in component
|
||||
@Component({
|
||||
template: `
|
||||
@if (showNewDashboard()) {
|
||||
<app-new-dashboard />
|
||||
} @else {
|
||||
<app-old-dashboard />
|
||||
}
|
||||
`
|
||||
})
|
||||
export class DashboardComponent {
|
||||
private featureFlags = inject(FeatureFlagService);
|
||||
showNewDashboard = computed(() => this.featureFlags.isEnabled('new-dashboard'));
|
||||
}
|
||||
|
||||
// Usage in routes
|
||||
{
|
||||
path: 'beta',
|
||||
loadComponent: () => import('./beta.component'),
|
||||
canActivate: [() => inject(FeatureFlagService).isEnabled('beta-features')]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pattern 7: Caching Strategy
|
||||
|
||||
### Service-Level Cache
|
||||
|
||||
```typescript
|
||||
// core/services/cache.service.ts
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class CacheService {
|
||||
private cache = new Map<string, { data: any; timestamp: number }>();
|
||||
private TTL = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
get<T>(key: string): T | null {
|
||||
const cached = this.cache.get(key);
|
||||
if (!cached) return null;
|
||||
|
||||
if (Date.now() - cached.timestamp > this.TTL) {
|
||||
this.cache.delete(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
return cached.data as T;
|
||||
}
|
||||
|
||||
set(key: string, data: any) {
|
||||
this.cache.set(key, { data, timestamp: Date.now() });
|
||||
}
|
||||
|
||||
clear(key?: string) {
|
||||
if (key) {
|
||||
this.cache.delete(key);
|
||||
} else {
|
||||
this.cache.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Usage in service
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ProductService {
|
||||
private cache = inject(CacheService);
|
||||
private api = inject(ApiService);
|
||||
|
||||
getProducts(): Observable<Product[]> {
|
||||
const cached = this.cache.get<Product[]>('products');
|
||||
if (cached) {
|
||||
return of(cached);
|
||||
}
|
||||
|
||||
return this.api.get<Product[]>('products').pipe(
|
||||
tap(products => this.cache.set('products', products))
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pattern 8: Loading States
|
||||
|
||||
### Unified Loading Pattern
|
||||
|
||||
```typescript
|
||||
// core/models/loading-state.interface.ts
|
||||
export interface LoadingState<T> {
|
||||
loading: boolean;
|
||||
data: T | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
// Feature service
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ProductService {
|
||||
private state = signal<LoadingState<Product[]>>({
|
||||
loading: false,
|
||||
data: null,
|
||||
error: null
|
||||
});
|
||||
|
||||
state$ = computed(() => this.state());
|
||||
|
||||
loadProducts() {
|
||||
this.state.update(s => ({ ...s, loading: true, error: null }));
|
||||
|
||||
this.api.get<Product[]>('products').subscribe({
|
||||
next: data => this.state.set({ loading: false, data, error: null }),
|
||||
error: err => this.state.set({ loading: false, data: null, error: err.message })
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Component
|
||||
@Component({
|
||||
template: `
|
||||
@if (state().loading) {
|
||||
<app-loading-spinner />
|
||||
} @else if (state().error) {
|
||||
<app-error-message [message]="state().error" />
|
||||
} @else if (state().data) {
|
||||
@for (product of state().data; track product.id) {
|
||||
<app-product-card [product]="product" />
|
||||
}
|
||||
}
|
||||
`
|
||||
})
|
||||
export class ProductListComponent {
|
||||
private service = inject(ProductService);
|
||||
state = this.service.state$;
|
||||
|
||||
ngOnInit() {
|
||||
this.service.loadProducts();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Team Structure Patterns
|
||||
|
||||
### Pattern 1: Feature Teams
|
||||
- Each team owns complete features
|
||||
- Vertical slice (UI + API + DB)
|
||||
- Autonomous deployment
|
||||
- Best for: Medium to large teams (10-50+)
|
||||
|
||||
### Pattern 2: Layer Teams
|
||||
- Frontend team, backend team
|
||||
- Horizontal slice
|
||||
- Coordinated deployment
|
||||
- Best for: Small teams (5-10)
|
||||
|
||||
### Pattern 3: Component Teams
|
||||
- Shared component library team
|
||||
- Feature teams consume components
|
||||
- Hybrid approach
|
||||
- Best for: Large organizations (50+)
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
Enterprise patterns ensure:
|
||||
- ✅ Consistent codebase across large teams
|
||||
- ✅ Predictable structure for new developers
|
||||
- ✅ Separation of concerns
|
||||
- ✅ Testable, maintainable code
|
||||
- ✅ Scalability from day one
|
||||
|
||||
**Key Takeaway:** Patterns create consistency. Consistency enables scale.
|
||||
471
skills/router-first-methodology/SKILL.md
Normal file
471
skills/router-first-methodology/SKILL.md
Normal file
@@ -0,0 +1,471 @@
|
||||
# Router-First Methodology
|
||||
|
||||
**Author:** Doguhan Uluca
|
||||
**Source:** Angular for Enterprise Applications, 3rd Edition
|
||||
**Context:** Enterprise Angular architecture for teams of 5-100+ developers
|
||||
|
||||
---
|
||||
|
||||
## Core Concept
|
||||
|
||||
**Router-First Architecture** is a methodology that enforces designing your application's routing structure BEFORE implementing components. This approach ensures high-level thinking, team consensus, and scalable architecture from day one.
|
||||
|
||||
---
|
||||
|
||||
## Why Router-First?
|
||||
|
||||
Traditional development often starts with components, leading to:
|
||||
- ❌ Unclear application structure
|
||||
- ❌ Tight coupling between features
|
||||
- ❌ Difficult to refactor later
|
||||
- ❌ Hard to parallelize team work
|
||||
- ❌ Performance issues at scale
|
||||
|
||||
Router-First solves this by:
|
||||
- ✅ Forcing architectural decisions early
|
||||
- ✅ Creating clear feature boundaries
|
||||
- ✅ Enabling lazy loading from the start
|
||||
- ✅ Facilitating team collaboration
|
||||
- ✅ Making the app structure visible in code
|
||||
|
||||
---
|
||||
|
||||
## The 7 Steps
|
||||
|
||||
### Step 1: Develop a Roadmap and Scope
|
||||
|
||||
**Goal:** Define what features your application needs
|
||||
|
||||
**Process:**
|
||||
1. List all user-facing features
|
||||
2. Identify MVP vs. future features
|
||||
3. Group related functionality
|
||||
4. Define user roles and permissions
|
||||
|
||||
**Example:**
|
||||
```
|
||||
E-commerce App Roadmap:
|
||||
|
||||
Phase 1 (MVP):
|
||||
- Product browsing
|
||||
- Shopping cart
|
||||
- Checkout
|
||||
- User authentication
|
||||
|
||||
Phase 2:
|
||||
- Order history
|
||||
- Product reviews
|
||||
- Wishlist
|
||||
- Admin panel
|
||||
|
||||
Phase 3:
|
||||
- Analytics dashboard
|
||||
- Inventory management
|
||||
- Customer support
|
||||
```
|
||||
|
||||
**Output:** Feature list with priorities
|
||||
|
||||
---
|
||||
|
||||
### Step 2: Design with Lazy Loading in Mind
|
||||
|
||||
**Goal:** Plan bundle structure for optimal performance
|
||||
|
||||
**Process:**
|
||||
1. Each major feature = separate lazy-loaded module
|
||||
2. Identify shared dependencies
|
||||
3. Plan loading strategies
|
||||
4. Set bundle size budgets
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
// Bundle planning
|
||||
Initial Load (Critical Path):
|
||||
- Authentication (50 KB)
|
||||
- Layout shell (30 KB)
|
||||
- Core services (40 KB)
|
||||
Total: 120 KB ✅
|
||||
|
||||
Lazy Loaded Features:
|
||||
- Dashboard (60 KB)
|
||||
- Products (80 KB)
|
||||
- Orders (45 KB)
|
||||
- Admin (120 KB)
|
||||
|
||||
Strategy:
|
||||
- Preload Dashboard after login
|
||||
- Lazy load others on-demand
|
||||
- Code split large features
|
||||
```
|
||||
|
||||
**Anti-pattern:**
|
||||
```typescript
|
||||
// ❌ BAD: Everything imported at root
|
||||
import { DashboardModule } from './dashboard';
|
||||
import { ProductsModule } from './products';
|
||||
import { OrdersModule } from './orders';
|
||||
```
|
||||
|
||||
**Best Practice:**
|
||||
```typescript
|
||||
// ✅ GOOD: Lazy loaded via routes
|
||||
{
|
||||
path: 'dashboard',
|
||||
loadChildren: () => import('./dashboard/dashboard.routes')
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Step 3: Implement Walking-Skeleton Navigation
|
||||
|
||||
**Goal:** Create navigable shell with placeholder content
|
||||
|
||||
**Process:**
|
||||
1. Define all routes in app.routes.ts
|
||||
2. Create shell components (empty templates)
|
||||
3. Verify navigation works
|
||||
4. Add breadcrumbs and titles
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
// app.routes.ts - Walking skeleton
|
||||
export const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
redirectTo: '/dashboard',
|
||||
pathMatch: 'full'
|
||||
},
|
||||
{
|
||||
path: 'dashboard',
|
||||
loadComponent: () => import('./features/dashboard/dashboard.component')
|
||||
.then(m => m.DashboardComponent),
|
||||
data: { breadcrumb: 'Dashboard' }
|
||||
},
|
||||
{
|
||||
path: 'products',
|
||||
loadComponent: () => import('./features/products/products.component')
|
||||
.then(m => m.ProductsComponent),
|
||||
data: { breadcrumb: 'Products' }
|
||||
},
|
||||
{
|
||||
path: 'orders',
|
||||
loadComponent: () => import('./features/orders/orders.component')
|
||||
.then(m => m.OrdersComponent),
|
||||
data: { breadcrumb: 'Orders' }
|
||||
}
|
||||
];
|
||||
```
|
||||
|
||||
```typescript
|
||||
// dashboard.component.ts - Shell component
|
||||
@Component({
|
||||
selector: 'app-dashboard',
|
||||
standalone: true,
|
||||
template: `
|
||||
<h1>Dashboard</h1>
|
||||
<p>Coming soon...</p>
|
||||
`
|
||||
})
|
||||
export class DashboardComponent {}
|
||||
```
|
||||
|
||||
**Benefit:** Team can navigate the app before any features are implemented
|
||||
|
||||
---
|
||||
|
||||
### Step 4: Achieve Stateless, Data-Driven Design
|
||||
|
||||
**Goal:** Components receive data, don't manage global state
|
||||
|
||||
**Process:**
|
||||
1. Services handle state and HTTP
|
||||
2. Components receive data via inputs/signals
|
||||
3. Components emit events, not side effects
|
||||
4. Use observables for async data
|
||||
|
||||
**Example:**
|
||||
|
||||
```typescript
|
||||
// ❌ BAD: Component manages state
|
||||
@Component({...})
|
||||
export class ProductListComponent {
|
||||
products: Product[] = [];
|
||||
|
||||
constructor(private http: HttpClient) {
|
||||
this.http.get('/api/products').subscribe(data => {
|
||||
this.products = data;
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// ✅ GOOD: Service manages state
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ProductService {
|
||||
private products$ = new BehaviorSubject<Product[]>([]);
|
||||
|
||||
getProducts(): Observable<Product[]> {
|
||||
return this.http.get<Product[]>('/api/products').pipe(
|
||||
tap(products => this.products$.next(products))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Component({...})
|
||||
export class ProductListComponent {
|
||||
products$ = inject(ProductService).getProducts();
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Step 5: Enforce Decoupled Component Architecture
|
||||
|
||||
**Goal:** Separate smart (container) and dumb (presentational) components
|
||||
|
||||
**Smart Components:**
|
||||
- Manage data fetching
|
||||
- Handle business logic
|
||||
- Communicate with services
|
||||
- Located in feature folders
|
||||
|
||||
**Dumb Components:**
|
||||
- Receive data via @Input
|
||||
- Emit events via @Output
|
||||
- No business logic
|
||||
- Located in shared folder
|
||||
|
||||
**Example:**
|
||||
|
||||
```typescript
|
||||
// Smart component (container)
|
||||
@Component({
|
||||
selector: 'app-product-list',
|
||||
template: `
|
||||
@for (product of products(); track product.id) {
|
||||
<app-product-card
|
||||
[product]="product"
|
||||
(addToCart)="handleAddToCart($event)"
|
||||
/>
|
||||
}
|
||||
`
|
||||
})
|
||||
export class ProductListComponent {
|
||||
private productService = inject(ProductService);
|
||||
products = toSignal(this.productService.getProducts());
|
||||
|
||||
handleAddToCart(productId: string) {
|
||||
this.cartService.addItem(productId);
|
||||
}
|
||||
}
|
||||
|
||||
// Dumb component (presentational)
|
||||
@Component({
|
||||
selector: 'app-product-card',
|
||||
template: `
|
||||
<div class="card">
|
||||
<h3>{{ product.name }}</h3>
|
||||
<p>{{ product.price | currency }}</p>
|
||||
<button (click)="addToCart.emit(product.id)">
|
||||
Add to Cart
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
export class ProductCardComponent {
|
||||
@Input({ required: true }) product!: Product;
|
||||
@Output() addToCart = new EventEmitter<string>();
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Step 6: Differentiate User Controls vs Components
|
||||
|
||||
**Goal:** Clear separation between reusable UI and feature-specific components
|
||||
|
||||
**User Controls (Shared):**
|
||||
- Generic UI elements
|
||||
- No business logic
|
||||
- Highly reusable
|
||||
- Location: `shared/components/`
|
||||
|
||||
**Feature Components:**
|
||||
- Feature-specific logic
|
||||
- Use shared controls
|
||||
- Business logic included
|
||||
- Location: `features/<feature>/components/`
|
||||
|
||||
**Example Structure:**
|
||||
|
||||
```
|
||||
shared/components/ # User Controls
|
||||
├── button/
|
||||
├── input/
|
||||
├── card/
|
||||
├── modal/
|
||||
└── data-table/
|
||||
|
||||
features/products/ # Feature Components
|
||||
├── product-list/
|
||||
├── product-detail/
|
||||
├── product-form/
|
||||
└── product-search/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Step 7: Maximize Code Reuse
|
||||
|
||||
**Goal:** DRY principle with TypeScript and ES features
|
||||
|
||||
**Techniques:**
|
||||
|
||||
1. **Shared Utilities**
|
||||
```typescript
|
||||
// shared/utils/date.utils.ts
|
||||
export function formatDate(date: Date): string {
|
||||
return date.toLocaleDateString('en-US');
|
||||
}
|
||||
```
|
||||
|
||||
2. **Shared Interfaces**
|
||||
```typescript
|
||||
// core/models/api-response.interface.ts
|
||||
export interface ApiResponse<T> {
|
||||
data: T;
|
||||
message: string;
|
||||
status: number;
|
||||
}
|
||||
```
|
||||
|
||||
3. **Base Classes (use sparingly)**
|
||||
```typescript
|
||||
// core/base/base-component.ts
|
||||
export abstract class BaseComponent implements OnDestroy {
|
||||
protected destroy$ = new Subject<void>();
|
||||
|
||||
ngOnDestroy() {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
4. **Mixins**
|
||||
```typescript
|
||||
// shared/mixins/timestamp.mixin.ts
|
||||
export function WithTimestamp<T extends Constructor>(Base: T) {
|
||||
return class extends Base {
|
||||
createdAt = new Date();
|
||||
updatedAt = new Date();
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Real-World Application
|
||||
|
||||
### Case Study: E-commerce Platform
|
||||
|
||||
**Team:** 15 developers
|
||||
**Timeline:** 6 months
|
||||
**Features:** 12 major features
|
||||
|
||||
**Router-First Implementation:**
|
||||
|
||||
1. **Week 1:** Route planning
|
||||
- Defined all 12 features as routes
|
||||
- Created walking skeleton
|
||||
- Team reviewed and agreed on structure
|
||||
|
||||
2. **Week 2-3:** Core setup
|
||||
- Implemented auth guards
|
||||
- Set up core services
|
||||
- Created shared components
|
||||
|
||||
3. **Week 4-24:** Parallel development
|
||||
- 3 teams worked on different features simultaneously
|
||||
- No merge conflicts (clear boundaries)
|
||||
- Easy to track progress (routes visible)
|
||||
|
||||
4. **Result:**
|
||||
- On-time delivery
|
||||
- 185 KB initial bundle
|
||||
- 45 KB average feature bundle
|
||||
- Easy onboarding for new devs
|
||||
|
||||
---
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
### 1. Starting with Components
|
||||
```typescript
|
||||
// ❌ WRONG ORDER
|
||||
1. Build dashboard component
|
||||
2. Build product list component
|
||||
3. Figure out routing later
|
||||
|
||||
// ✅ CORRECT ORDER
|
||||
1. Design routes
|
||||
2. Create shell components
|
||||
3. Implement features
|
||||
```
|
||||
|
||||
### 2. Tight Coupling
|
||||
```typescript
|
||||
// ❌ BAD: Direct component dependencies
|
||||
export class DashboardComponent {
|
||||
constructor(private productList: ProductListComponent) {}
|
||||
}
|
||||
|
||||
// ✅ GOOD: Service-based communication
|
||||
export class DashboardComponent {
|
||||
constructor(private productService: ProductService) {}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Ignoring Lazy Loading
|
||||
```typescript
|
||||
// ❌ BAD: Eager loading everything
|
||||
imports: [
|
||||
DashboardModule,
|
||||
ProductsModule,
|
||||
OrdersModule
|
||||
]
|
||||
|
||||
// ✅ GOOD: Lazy load features
|
||||
{
|
||||
path: 'dashboard',
|
||||
loadChildren: () => import('./dashboard/dashboard.routes')
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Checklist
|
||||
|
||||
Before claiming Router-First compliance:
|
||||
|
||||
- [ ] Routes defined before component implementation
|
||||
- [ ] All features lazy loaded (except critical path)
|
||||
- [ ] Walking skeleton navigation works
|
||||
- [ ] Smart/Dumb component separation
|
||||
- [ ] Services manage state, not components
|
||||
- [ ] Shared components in shared folder
|
||||
- [ ] Feature components in feature folders
|
||||
- [ ] Clear team agreement on structure
|
||||
- [ ] Bundle size budgets defined
|
||||
- [ ] Documentation of routing decisions
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
Router-First Architecture is about **planning before building**. By designing routes first, you create a scalable, maintainable, and performant Angular application that grows with your team.
|
||||
|
||||
**Key Takeaway:** If you can see your entire application structure by looking at app.routes.ts, you're doing it right.
|
||||
Reference in New Issue
Block a user