Angular App Architecture — Feature Modules, Routing and State

The Angular frontend architecture for the classified website applies all the patterns built in Parts 5 and 7: lazy-loaded feature routes, signal-based state, HTTP interceptors, Angular Material theming, and reactive forms. Feature-based organisation keeps the codebase navigable — every piece of code for the “browse” feature lives in features/browse/, making it easy to find, modify, and test without touching other features.

App Architecture and Routing

// ── app.routes.ts — lazy-loaded feature routes ────────────────────────────
export const appRoutes: Routes = [
  { path: '',          redirectTo: 'browse', pathMatch: 'full' },

  // Public routes
  {
    path:         'browse',
    loadChildren: () => import('./features/browse/browse.routes')
                          .then(m => m.browseRoutes),
  },
  {
    path:         'listings',
    loadChildren: () => import('./features/listings/listings.routes')
                          .then(m => m.listingsRoutes),
  },
  {
    path:         'sellers',
    loadChildren: () => import('./features/sellers/sellers.routes')
                          .then(m => m.sellersRoutes),
  },

  // Auth routes (preloaded — users need login fast)
  {
    path:         'auth',
    loadChildren: () => import('./features/auth/auth.routes')
                          .then(m => m.authRoutes),
  },

  // Seller-only routes
  {
    path:         'my-listings',
    loadChildren: () => import('./features/seller/seller.routes')
                          .then(m => m.sellerRoutes),
    canActivate:  [authGuard],
    data:         { requiredRole: 'Seller' },
  },

  // Admin routes
  {
    path:         'admin',
    loadChildren: () => import('./features/admin/admin.routes')
                          .then(m => m.adminRoutes),
    canActivate:  [authGuard, roleGuard],
    data:         { requiredRole: 'Moderator' },
  },

  { path: '**', redirectTo: 'browse' },
];

// ── Core signal-based search state ────────────────────────────────────────
@Injectable({ providedIn: 'root' })
export class SearchStateService {
  private router = inject(Router);
  private route  = inject(ActivatedRoute);

  // Signals — reactive source of truth for all filters
  keyword   = signal<string>('');
  category  = signal<Category | null>(null);
  city      = signal<string>('');
  minPrice  = signal<number | null>(null);
  maxPrice  = signal<number | null>(null);
  page      = signal<number>(1);

  // Derived query params object for API calls
  readonly params = computed(() => ({
    keyword:  this.keyword()  || undefined,
    category: this.category() ?? undefined,
    city:     this.city()     || undefined,
    minPrice: this.minPrice() ?? undefined,
    maxPrice: this.maxPrice() ?? undefined,
    page:     this.page(),
  }));

  // Sync URL → signals on init
  syncFromUrl(params: ParamMap): void {
    this.keyword.set(params.get('keyword') ?? '');
    this.category.set(params.get('category') as Category ?? null);
    this.city.set(params.get('city') ?? '');
    this.minPrice.set(params.get('minPrice') ? +params.get('minPrice')! : null);
    this.maxPrice.set(params.get('maxPrice') ? +params.get('maxPrice')! : null);
    this.page.set(params.get('page') ? +params.get('page')! : 1);
  }

  // Sync signals → URL
  updateUrl(partial: Partial<SearchParams>): void {
    this.router.navigate(['/browse'], {
      queryParams: {
        keyword:  partial.keyword  ?? this.keyword()  || null,
        category: partial.category ?? this.category() ?? null,
        city:     partial.city     ?? this.city()     || null,
        minPrice: partial.minPrice ?? this.minPrice() ?? null,
        maxPrice: partial.maxPrice ?? this.maxPrice() ?? null,
        page:     partial.page     ?? 1,  // reset to page 1 on filter change
      },
    });
  }

  clearAll(): void {
    this.router.navigate(['/browse']);
  }
}
Note: The SearchStateService is the single source of truth for all search filter state. Components read from and write to this service rather than managing their own filter state. The service synchronises bidirectionally with the URL — URL changes update the signals, signal changes update the URL. This means the browser back button restores previous search state, and sharing a URL restores the exact same search. This is the correct architecture for any multi-filter search page.
Tip: Use data: { requiredRole: 'Seller' } on routes combined with a roleGuard that reads the required role from route data. This keeps role requirements co-located with route definitions (easy to audit — one file shows all protected routes) rather than hardcoded in individual guards. The roleGuard reads route.data['requiredRole'] and checks it against the current user’s claims, redirecting to the seller registration page if the role is missing.
Warning: Lazy-loaded feature routes create separate JavaScript chunks that are downloaded on first navigation to that route. This is excellent for initial page load (users don’t download admin code) but adds a brief delay on first visit to each feature. Use Angular’s PreloadAllModules strategy (or a custom preloading strategy) to background-preload other feature chunks after the initial route loads — eliminating the delay for subsequent navigations while keeping the initial bundle small.

Common Mistakes

Mistake 1 — Global mutable state in services (search filters reset on navigation)

❌ Wrong — component-local signals for filters; navigating away then back resets all filters; poor UX.

✅ Correct — SearchStateService (provided in root) persists filter state across navigation; URL persistence for share/bookmark.

Mistake 2 — Eager-loading all feature modules (large initial bundle)

❌ Wrong — all routes in the main bundle; initial bundle 2MB+; slow first load especially on mobile.

✅ Correct — lazy-loaded feature routes; initial bundle ~200KB; each feature loaded on demand.

🧠 Test Yourself

A user searches for “bicycle” in Bristol, navigates to a listing detail, then clicks back. Does the search state (keyword=”bicycle”, city=”Bristol”) persist?