Skip to main content
Angular Architecture

Module Overview

Estimated Time: 4-5 hours | Difficulty: Advanced | Prerequisites: Module 11
As applications grow, proper architecture becomes critical. This module covers advanced patterns for state management, component design, and application structure. What You’ll Learn:
  • Smart vs Presentational components
  • Container pattern
  • Facade pattern for state management
  • Feature modules and domain-driven design
  • Dynamic components
  • Content projection patterns

Smart vs Presentational Components

┌─────────────────────────────────────────────────────────────────────────┐
│              Component Classification                                    │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                          │
│   SMART (Container) Components        PRESENTATIONAL (Dumb) Components  │
│   ─────────────────────────────       ───────────────────────────────   │
│   • Know about services               • Pure, no side effects           │
│   • Manage state                      • Only know about inputs/outputs  │
│   • Handle business logic             • Highly reusable                 │
│   • Coordinate child components       • Easy to test                    │
│   • Usually pages/features            • Usually UI elements             │
│                                                                          │
│   Example:                             Example:                          │
│   ┌─────────────────────────┐        ┌─────────────────────────┐       │
│   │   UsersPageComponent    │        │    UserCardComponent    │       │
│   │   ─────────────────     │        │    ────────────────     │       │
│   │   - injects UserService │        │    - @Input() user      │       │
│   │   - loads users         │        │    - @Output() select   │       │
│   │   - handles CRUD        │        │    - pure template      │       │
│   └─────────────────────────┘        └─────────────────────────┘       │
│                                                                          │
└─────────────────────────────────────────────────────────────────────────┘

Presentational Component

// user-card.component.ts
@Component({
  selector: 'app-user-card',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <div class="card" (click)="cardClicked()">
      <img [src]="user().avatar" [alt]="user().name" />
      <h3>{{ user().name }}</h3>
      <p>{{ user().email }}</p>
      <span class="role">{{ user().role }}</span>
    </div>
  `
})
export class UserCardComponent {
  user = input.required<User>();
  select = output<User>();
  
  cardClicked() {
    this.select.emit(this.user());
  }
}

Smart Component

// users-page.component.ts
@Component({
  selector: 'app-users-page',
  standalone: true,
  imports: [UserCardComponent, UserFormComponent],
  template: `
    <div class="users-page">
      <header>
        <h1>Users</h1>
        <button (click)="showForm.set(true)">Add User</button>
      </header>
      
      @if (loading()) {
        <app-spinner />
      } @else {
        <div class="grid">
          @for (user of users(); track user.id) {
            <app-user-card 
              [user]="user"
              (select)="selectUser($event)"
            />
          }
        </div>
      }
      
      @if (showForm()) {
        <app-user-form
          [user]="selectedUser()"
          (save)="saveUser($event)"
          (cancel)="closeForm()"
        />
      }
    </div>
  `
})
export class UsersPageComponent implements OnInit {
  private userService = inject(UserService);
  
  users = signal<User[]>([]);
  loading = signal(false);
  selectedUser = signal<User | null>(null);
  showForm = signal(false);
  
  ngOnInit() {
    this.loadUsers();
  }
  
  loadUsers() {
    this.loading.set(true);
    this.userService.getUsers().subscribe({
      next: (users) => {
        this.users.set(users);
        this.loading.set(false);
      },
      error: () => this.loading.set(false)
    });
  }
  
  selectUser(user: User) {
    this.selectedUser.set(user);
    this.showForm.set(true);
  }
  
  saveUser(user: User) {
    const save$ = user.id 
      ? this.userService.updateUser(user.id, user)
      : this.userService.createUser(user);
    
    save$.subscribe(() => {
      this.loadUsers();
      this.closeForm();
    });
  }
  
  closeForm() {
    this.selectedUser.set(null);
    this.showForm.set(false);
  }
}

Facade Pattern

Simplify complex state management with facades:
// users.facade.ts
@Injectable({ providedIn: 'root' })
export class UsersFacade {
  private userService = inject(UserService);
  private notificationService = inject(NotificationService);
  private router = inject(Router);
  
  // State
  private _users = signal<User[]>([]);
  private _loading = signal(false);
  private _error = signal<string | null>(null);
  private _selectedUser = signal<User | null>(null);
  
  // Public selectors (read-only)
  readonly users = this._users.asReadonly();
  readonly loading = this._loading.asReadonly();
  readonly error = this._error.asReadonly();
  readonly selectedUser = this._selectedUser.asReadonly();
  
  // Computed values
  readonly userCount = computed(() => this._users().length);
  readonly activeUsers = computed(() => 
    this._users().filter(u => u.active)
  );
  
  // Actions
  loadUsers() {
    this._loading.set(true);
    this._error.set(null);
    
    this.userService.getUsers().pipe(
      catchError(error => {
        this._error.set('Failed to load users');
        return of([]);
      }),
      finalize(() => this._loading.set(false))
    ).subscribe(users => this._users.set(users));
  }
  
  selectUser(id: number) {
    const user = this._users().find(u => u.id === id);
    this._selectedUser.set(user ?? null);
  }
  
  async createUser(userData: CreateUserDto) {
    this._loading.set(true);
    
    try {
      const user = await firstValueFrom(
        this.userService.createUser(userData)
      );
      this._users.update(users => [...users, user]);
      this.notificationService.success('User created');
      this.router.navigate(['/users', user.id]);
      return user;
    } catch (error) {
      this.notificationService.error('Failed to create user');
      throw error;
    } finally {
      this._loading.set(false);
    }
  }
  
  async updateUser(id: number, updates: Partial<User>) {
    this._loading.set(true);
    
    try {
      const updated = await firstValueFrom(
        this.userService.updateUser(id, updates)
      );
      this._users.update(users =>
        users.map(u => u.id === id ? updated : u)
      );
      if (this._selectedUser()?.id === id) {
        this._selectedUser.set(updated);
      }
      this.notificationService.success('User updated');
      return updated;
    } catch (error) {
      this.notificationService.error('Failed to update user');
      throw error;
    } finally {
      this._loading.set(false);
    }
  }
  
  async deleteUser(id: number) {
    if (!confirm('Delete this user?')) return;
    
    try {
      await firstValueFrom(this.userService.deleteUser(id));
      this._users.update(users => users.filter(u => u.id !== id));
      if (this._selectedUser()?.id === id) {
        this._selectedUser.set(null);
      }
      this.notificationService.success('User deleted');
    } catch (error) {
      this.notificationService.error('Failed to delete user');
    }
  }
}
// users-page.component.ts - Much simpler with facade
@Component({
  selector: 'app-users-page',
  standalone: true,
  template: `
    @if (facade.loading()) {
      <app-spinner />
    } @else if (facade.error()) {
      <app-error [message]="facade.error()" (retry)="facade.loadUsers()" />
    } @else {
      <app-users-list 
        [users]="facade.users()"
        (select)="facade.selectUser($event)"
        (delete)="facade.deleteUser($event)"
      />
    }
  `
})
export class UsersPageComponent implements OnInit {
  facade = inject(UsersFacade);
  
  ngOnInit() {
    this.facade.loadUsers();
  }
}

Feature Module Structure

┌─────────────────────────────────────────────────────────────────────────┐
│              Feature Module Structure                                    │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                          │
│   src/                                                                   │
│   └── app/                                                               │
│       ├── core/                    # Singleton services                  │
│       │   ├── services/                                                  │
│       │   ├── guards/                                                    │
│       │   ├── interceptors/                                              │
│       │   └── core.module.ts                                            │
│       │                                                                  │
│       ├── shared/                  # Reusable components                 │
│       │   ├── components/                                                │
│       │   ├── directives/                                                │
│       │   ├── pipes/                                                     │
│       │   └── shared.module.ts                                          │
│       │                                                                  │
│       └── features/                # Feature modules                     │
│           ├── users/                                                     │
│           │   ├── components/      # Presentational                     │
│           │   ├── containers/      # Smart components                   │
│           │   ├── services/        # Feature-specific                   │
│           │   ├── models/                                                │
│           │   ├── state/           # Feature state (facade/store)       │
│           │   ├── users.routes.ts                                        │
│           │   └── index.ts         # Public API                         │
│           │                                                              │
│           └── products/                                                  │
│               └── ...                                                    │
│                                                                          │
└─────────────────────────────────────────────────────────────────────────┘

Feature Routes

// features/users/users.routes.ts
export const USERS_ROUTES: Routes = [
  {
    path: '',
    component: UsersLayoutComponent,
    children: [
      { path: '', component: UsersListComponent },
      { path: 'new', component: UserFormComponent },
      { 
        path: ':id', 
        component: UserDetailComponent,
        resolve: { user: userResolver }
      },
      { 
        path: ':id/edit', 
        component: UserFormComponent,
        resolve: { user: userResolver }
      }
    ]
  }
];

// app.routes.ts
export const routes: Routes = [
  { path: '', redirectTo: 'dashboard', pathMatch: 'full' },
  { path: 'dashboard', component: DashboardComponent },
  {
    path: 'users',
    loadChildren: () => import('./features/users/users.routes')
      .then(m => m.USERS_ROUTES)
  },
  {
    path: 'products',
    loadChildren: () => import('./features/products/products.routes')
      .then(m => m.PRODUCTS_ROUTES)
  }
];

Content Projection

Basic Projection

// card.component.ts
@Component({
  selector: 'app-card',
  standalone: true,
  template: `
    <div class="card">
      <header class="card-header">
        <ng-content select="[card-header]" />
      </header>
      
      <div class="card-body">
        <ng-content />
      </div>
      
      <footer class="card-footer">
        <ng-content select="[card-footer]" />
      </footer>
    </div>
  `
})
export class CardComponent {}

// Usage
@Component({
  template: `
    <app-card>
      <h2 card-header>Card Title</h2>
      
      <p>This is the main content</p>
      <p>It goes in the body</p>
      
      <button card-footer>Action</button>
    </app-card>
  `
})

Conditional Projection with ngTemplateOutlet

// data-list.component.ts
@Component({
  selector: 'app-data-list',
  standalone: true,
  imports: [NgTemplateOutlet],
  template: `
    @if (loading) {
      <ng-container *ngTemplateOutlet="loadingTemplate || defaultLoading" />
    } @else if (items.length === 0) {
      <ng-container *ngTemplateOutlet="emptyTemplate || defaultEmpty" />
    } @else {
      @for (item of items; track item) {
        <ng-container 
          *ngTemplateOutlet="itemTemplate; context: { $implicit: item }"
        />
      }
    }
    
    <ng-template #defaultLoading>
      <div class="loading">Loading...</div>
    </ng-template>
    
    <ng-template #defaultEmpty>
      <div class="empty">No items found</div>
    </ng-template>
  `
})
export class DataListComponent<T> {
  @Input() items: T[] = [];
  @Input() loading = false;
  
  @ContentChild('item') itemTemplate!: TemplateRef<{ $implicit: T }>;
  @ContentChild('loading') loadingTemplate?: TemplateRef<void>;
  @ContentChild('empty') emptyTemplate?: TemplateRef<void>;
}

// Usage
@Component({
  template: `
    <app-data-list [items]="users" [loading]="isLoading">
      <ng-template #item let-user>
        <div class="user-row">
          <span>{{ user.name }}</span>
          <span>{{ user.email }}</span>
        </div>
      </ng-template>
      
      <ng-template #loading>
        <app-skeleton-rows [count]="5" />
      </ng-template>
      
      <ng-template #empty>
        <div class="no-users">
          <img src="empty.svg" />
          <p>No users yet. Create one!</p>
        </div>
      </ng-template>
    </app-data-list>
  `
})

Dynamic Components

// dynamic-modal.service.ts
@Injectable({ providedIn: 'root' })
export class ModalService {
  private viewContainerRef!: ViewContainerRef;
  
  setContainer(vcr: ViewContainerRef) {
    this.viewContainerRef = vcr;
  }
  
  open<C, R = any>(
    component: Type<C>,
    config?: { inputs?: Partial<C>; }
  ): Observable<R> {
    const componentRef = this.viewContainerRef.createComponent(component);
    
    // Set inputs
    if (config?.inputs) {
      Object.assign(componentRef.instance, config.inputs);
    }
    
    // Handle close
    const result$ = new Subject<R>();
    
    if ('close' in componentRef.instance) {
      (componentRef.instance as any).close.subscribe((result: R) => {
        result$.next(result);
        result$.complete();
        componentRef.destroy();
      });
    }
    
    return result$.asObservable();
  }
}

// confirm-dialog.component.ts
@Component({
  selector: 'app-confirm-dialog',
  standalone: true,
  template: `
    <div class="modal-backdrop" (click)="close.emit(false)">
      <div class="modal" (click)="$event.stopPropagation()">
        <h2>{{ title }}</h2>
        <p>{{ message }}</p>
        <div class="actions">
          <button (click)="close.emit(false)">Cancel</button>
          <button class="primary" (click)="close.emit(true)">Confirm</button>
        </div>
      </div>
    </div>
  `
})
export class ConfirmDialogComponent {
  @Input() title = 'Confirm';
  @Input() message = 'Are you sure?';
  @Output() close = new EventEmitter<boolean>();
}

// Usage
@Component({...})
export class SomeComponent {
  private modalService = inject(ModalService);
  
  async deleteItem(item: Item) {
    const confirmed = await firstValueFrom(
      this.modalService.open(ConfirmDialogComponent, {
        inputs: {
          title: 'Delete Item',
          message: `Delete "${item.name}"?`
        }
      })
    );
    
    if (confirmed) {
      this.itemService.delete(item.id);
    }
  }
}

Control Value Accessor

Create custom form controls:
// rating.component.ts
@Component({
  selector: 'app-rating',
  standalone: true,
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: forwardRef(() => RatingComponent),
      multi: true
    }
  ],
  template: `
    <div class="rating" [class.disabled]="disabled">
      @for (star of stars; track star) {
        <button
          type="button"
          (click)="rate(star)"
          (mouseenter)="hover.set(star)"
          (mouseleave)="hover.set(0)"
          [class.active]="star <= (hover() || value())"
          [disabled]="disabled"
        >

        </button>
      }
    </div>
  `
})
export class RatingComponent implements ControlValueAccessor {
  stars = [1, 2, 3, 4, 5];
  value = signal(0);
  hover = signal(0);
  disabled = false;
  
  private onChange = (value: number) => {};
  private onTouched = () => {};
  
  // Called when form sets value
  writeValue(value: number): void {
    this.value.set(value || 0);
  }
  
  // Register callback for value changes
  registerOnChange(fn: (value: number) => void): void {
    this.onChange = fn;
  }
  
  // Register callback for touch
  registerOnTouched(fn: () => void): void {
    this.onTouched = fn;
  }
  
  // Called when form enables/disables
  setDisabledState(isDisabled: boolean): void {
    this.disabled = isDisabled;
  }
  
  rate(star: number): void {
    if (this.disabled) return;
    
    this.value.set(star);
    this.onChange(star);
    this.onTouched();
  }
}

// Usage with reactive forms
@Component({
  template: `
    <form [formGroup]="form">
      <app-rating formControlName="rating" />
    </form>
  `
})
export class ReviewFormComponent {
  form = new FormGroup({
    rating: new FormControl(0, Validators.required),
    comment: new FormControl('')
  });
}

Inheritance vs Composition

// ❌ Inheritance - tightly coupled, inflexible
class BaseListComponent<T> {
  items: T[] = [];
  loading = false;
  
  load() { /* ... */ }
  delete(id: number) { /* ... */ }
}

class UsersListComponent extends BaseListComponent<User> {
  // Hard to customize, tight coupling
}

// ✅ Composition - flexible, testable
@Injectable()
export class ListState<T extends { id: number }> {
  items = signal<T[]>([]);
  loading = signal(false);
  
  setItems(items: T[]) { this.items.set(items); }
  addItem(item: T) { this.items.update(i => [...i, item]); }
  removeItem(id: number) { this.items.update(i => i.filter(x => x.id !== id)); }
  updateItem(id: number, updates: Partial<T>) {
    this.items.update(items => 
      items.map(i => i.id === id ? { ...i, ...updates } : i)
    );
  }
}

@Component({
  providers: [ListState],  // Each component gets its own instance
  template: `...`
})
export class UsersListComponent {
  state = inject(ListState<User>);
  private userService = inject(UserService);
  
  ngOnInit() {
    this.state.loading.set(true);
    this.userService.getUsers().subscribe(users => {
      this.state.setItems(users);
      this.state.loading.set(false);
    });
  }
}

Practice Exercise

Exercise: Build a Generic Data Table Component

Create a reusable data table with:
  1. Generic type support
  2. Column configuration via input
  3. Sorting and pagination
  4. Row selection
  5. Custom cell templates via content projection
// data-table.types.ts
export interface ColumnDef<T> {
  key: keyof T | string;
  header: string;
  sortable?: boolean;
  width?: string;
}

// data-table.component.ts
@Component({
  selector: 'app-data-table',
  standalone: true,
  imports: [NgTemplateOutlet],
  template: `
    <table>
      <thead>
        <tr>
          @if (selectable) {
            <th class="checkbox-col">
              <input 
                type="checkbox"
                [checked]="allSelected()"
                (change)="toggleAll()"
              />
            </th>
          }
          @for (col of columns(); track col.key) {
            <th 
              [style.width]="col.width"
              [class.sortable]="col.sortable"
              (click)="col.sortable && toggleSort(col.key)"
            >
              {{ col.header }}
              @if (sortKey() === col.key) {
                <span>{{ sortDir() === 'asc' ? '▲' : '▼' }}</span>
              }
            </th>
          }
        </tr>
      </thead>
      
      <tbody>
        @for (row of paginatedData(); track trackByFn(row)) {
          <tr [class.selected]="isSelected(row)">
            @if (selectable) {
              <td>
                <input 
                  type="checkbox"
                  [checked]="isSelected(row)"
                  (change)="toggleSelection(row)"
                />
              </td>
            }
            @for (col of columns(); track col.key) {
              <td>
                @if (getCellTemplate(col.key); as template) {
                  <ng-container 
                    *ngTemplateOutlet="template; context: { $implicit: row, column: col }"
                  />
                } @else {
                  {{ getNestedValue(row, col.key) }}
                }
              </td>
            }
          </tr>
        }
      </tbody>
    </table>
    
    @if (paginate) {
      <div class="pagination">
        <button 
          [disabled]="page() === 1"
          (click)="page.set(page() - 1)"
        >
          Previous
        </button>
        <span>Page {{ page() }} of {{ totalPages() }}</span>
        <button 
          [disabled]="page() >= totalPages()"
          (click)="page.set(page() + 1)"
        >
          Next
        </button>
      </div>
    }
  `
})
export class DataTableComponent<T extends { id: number | string }> {
  data = input.required<T[]>();
  columns = input.required<ColumnDef<T>[]>();
  selectable = input(false);
  paginate = input(false);
  pageSize = input(10);
  trackByFn = input<(item: T) => any>((item) => item.id);
  
  selectionChange = output<T[]>();
  
  sortKey = signal<string | keyof T | null>(null);
  sortDir = signal<'asc' | 'desc'>('asc');
  page = signal(1);
  selected = signal<Set<any>>(new Set());
  
  @ContentChildren('cell') cellTemplates!: QueryList<TemplateRef<any>>;
  
  private cellTemplateMap = new Map<string, TemplateRef<any>>();
  
  sortedData = computed(() => {
    const data = [...this.data()];
    const key = this.sortKey();
    
    if (!key) return data;
    
    return data.sort((a, b) => {
      const aVal = this.getNestedValue(a, key);
      const bVal = this.getNestedValue(b, key);
      const dir = this.sortDir() === 'asc' ? 1 : -1;
      
      if (aVal < bVal) return -1 * dir;
      if (aVal > bVal) return 1 * dir;
      return 0;
    });
  });
  
  paginatedData = computed(() => {
    if (!this.paginate()) return this.sortedData();
    
    const start = (this.page() - 1) * this.pageSize();
    return this.sortedData().slice(start, start + this.pageSize());
  });
  
  totalPages = computed(() => 
    Math.ceil(this.data().length / this.pageSize())
  );
  
  allSelected = computed(() => {
    const current = this.paginatedData();
    return current.length > 0 && 
           current.every(row => this.selected().has(this.trackByFn()(row)));
  });
  
  toggleSort(key: string | keyof T) {
    if (this.sortKey() === key) {
      this.sortDir.update(d => d === 'asc' ? 'desc' : 'asc');
    } else {
      this.sortKey.set(key);
      this.sortDir.set('asc');
    }
  }
  
  isSelected(row: T): boolean {
    return this.selected().has(this.trackByFn()(row));
  }
  
  toggleSelection(row: T) {
    const id = this.trackByFn()(row);
    this.selected.update(set => {
      const newSet = new Set(set);
      if (newSet.has(id)) {
        newSet.delete(id);
      } else {
        newSet.add(id);
      }
      return newSet;
    });
    this.emitSelection();
  }
  
  toggleAll() {
    if (this.allSelected()) {
      this.selected.set(new Set());
    } else {
      const ids = this.paginatedData().map(row => this.trackByFn()(row));
      this.selected.set(new Set(ids));
    }
    this.emitSelection();
  }
  
  getCellTemplate(key: string | keyof T): TemplateRef<any> | null {
    return this.cellTemplateMap.get(String(key)) ?? null;
  }
  
  getNestedValue(obj: any, path: string | keyof T): any {
    return String(path).split('.').reduce((o, k) => o?.[k], obj);
  }
  
  private emitSelection() {
    const selectedItems = this.data().filter(row => 
      this.selected().has(this.trackByFn()(row))
    );
    this.selectionChange.emit(selectedItems);
  }
}

// Usage
@Component({
  template: `
    <app-data-table
      [data]="users()"
      [columns]="columns"
      [selectable]="true"
      [paginate]="true"
      [pageSize]="20"
      (selectionChange)="onSelectionChange($event)"
    >
      <ng-template #cell="avatar" let-user>
        <img [src]="user.avatar" class="avatar" />
      </ng-template>
      
      <ng-template #cell="actions" let-user>
        <button (click)="edit(user)">Edit</button>
        <button (click)="delete(user)">Delete</button>
      </ng-template>
    </app-data-table>
  `
})
export class UsersTableComponent {
  columns: ColumnDef<User>[] = [
    { key: 'avatar', header: '', width: '60px' },
    { key: 'name', header: 'Name', sortable: true },
    { key: 'email', header: 'Email', sortable: true },
    { key: 'role', header: 'Role', sortable: true },
    { key: 'actions', header: 'Actions', width: '150px' }
  ];
}

Summary

1

Smart/Presentational

Separate stateful containers from pure presentational components
2

Facade Pattern

Simplify complex state with a unified API
3

Feature Modules

Organize by domain with clear boundaries
4

Content Projection

Build flexible, customizable components
5

Composition over Inheritance

Prefer composable patterns for flexibility

Next Steps

Next: Server-Side Rendering

Learn SSR and hydration for better performance and SEO