Skip to main content

Overview

The frontend uses a service-oriented architecture for data fetching, business logic, and state management. All services are provided at the root level and use Angular’s HttpClient for API communication.

Service Architecture

Service Pattern

All services follow a consistent pattern:
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { environment } from '../../environments/environment';

@Injectable({ providedIn: 'root' })
export class ExampleService {
  private readonly api = `${environment.apiUrl}/endpoint`;
  private readonly http = inject(HttpClient);
  
  getAll(): Observable<Type[]> {
    return this.http.get<Type[]>(this.api);
  }
}
Key Features:
  • providedIn: 'root' - Singleton service
  • inject() function - Modern dependency injection
  • Type-safe HTTP calls with generics
  • Environment-based API URL configuration

HTTP Services

ProductService

Location: src/app/services/product.service.ts Purpose: Fetch product data
@Injectable({ providedIn: 'root' })
export class ProductService {
  private readonly api = `${environment.apiUrl}/products`;
  private readonly http = inject(HttpClient);

  getAll(): Observable<Product[]> {
    return this.http.get<Product[]>(this.api);
  }
}
API Endpoint: GET /api/products Usage:
this.productService.getAll()
  .pipe(takeUntil(this.destroy$))
  .subscribe({
    next: products => {
      this.products = products;
    },
    error: err => {
      console.error('Error loading products:', err);
    }
  });

StoreService

Location: src/app/services/store.service.ts Purpose: Fetch store locations
@Injectable({ providedIn: 'root' })
export class StoreService {
  private readonly api = `${environment.apiUrl}/stores`;
  private readonly http = inject(HttpClient);

  getAll(): Observable<Store[]> {
    return this.http.get<Store[]>(this.api);
  }
}
API Endpoint: GET /api/stores Returns: Array of stores with id, country, latitude, longitude

WarehouseService

Location: src/app/services/warehouse.service.ts Purpose: Fetch warehouse locations
@Injectable({ providedIn: 'root' })
export class WarehouseService {
  private readonly api = `${environment.apiUrl}/warehouses`;
  private readonly http = inject(HttpClient);

  getAll(): Observable<Warehouse[]> {
    return this.http.get<Warehouse[]>(this.api);
  }
}
API Endpoint: GET /api/warehouses Returns: Array of warehouses with id, country, latitude, longitude

StockAssignmentService

Location: src/app/services/stock-assignment.service.ts Purpose: Fetch stock assignments with optional filtering
@Injectable({ providedIn: 'root' })
export class StockAssignmentService {
  private readonly api = `${environment.apiUrl}/stock-assignments`;
  private readonly http = inject(HttpClient);

  getAll(filters?: DashboardFilters): Observable<StockAssignment[]> {
    let params = new HttpParams();

    if (filters?.storeId) {
      params = params.set('storeId', filters.storeId);
    }
    if (filters?.warehouseId) {
      params = params.set('warehouseId', filters.warehouseId);
    }
    if (filters?.productId) {
      params = params.set('productId', filters.productId);
    }

    return this.http.get<StockAssignment[]>(this.api, { params });
  }
}
API Endpoint: GET /api/stock-assignments Query Parameters:
  • storeId (optional) - Filter by store
  • warehouseId (optional) - Filter by warehouse
  • productId (optional) - Filter by product
Usage:
const filters: DashboardFilters = {
  warehouseId: 'WH-001',
  storeId: null,
  productId: 'PROD-001'
};

this.stockAssignmentService.getAll(filters)
  .subscribe(assignments => {
    // Filtered assignments
  });

MetricsService

Location: src/app/services/metrics.service.ts Purpose: Fetch global and detailed metrics
@Injectable({ providedIn: 'root' })
export class MetricsService {
  private readonly api = `${environment.apiUrl}/metrics`;
  private readonly http = inject(HttpClient);

  getGlobalMetrics(): Observable<GlobalMetrics> {
    return this.http.get<GlobalMetrics>(`${this.api}/global`);
  }

  getDetailedMetrics(criteria: MetricsCriteria): Observable<DetailedMetrics> {
    let params = new HttpParams();

    if (criteria.warehouseId) {
      params = params.set('warehouseId', criteria.warehouseId);
    }
    if (criteria.storeId) {
      params = params.set('storeId', criteria.storeId);
    }
    if (criteria.productId) {
      params = params.set('productId', criteria.productId);
    }

    return this.http.get<DetailedMetrics>(`${this.api}/detailed`, { params });
  }
}
API Endpoints:
  • GET /api/metrics/global - Global metrics (no filters)
  • GET /api/metrics/detailed - Filtered detailed metrics
Usage:
// Global metrics (on app init)
this.metricsService.getGlobalMetrics()
  .subscribe(metrics => {
    this.globalMetrics = metrics;
  });

// Detailed metrics (on filter change)
const criteria = {
  warehouseId: 'WH-001',
  storeId: undefined,
  productId: undefined
};

this.metricsService.getDetailedMetrics(criteria)
  .subscribe(metrics => {
    this.detailedMetrics = metrics;
  });

RxJS Patterns

Parallel Requests with forkJoin

Load multiple resources simultaneously:
import { forkJoin } from 'rxjs';
import { takeUntil, finalize } from 'rxjs/operators';

ngOnInit() {
  this.loading = true;
  forkJoin({
    warehouses: this.warehouseService.getAll(),
    stores: this.storeService.getAll(),
    products: this.productService.getAll(),
    globalMetrics: this.metricsService.getGlobalMetrics()
  })
  .pipe(
    takeUntil(this.destroy$),
    finalize(() => { this.loading = false; })
  )
  .subscribe({
    next: ({ warehouses, stores, products, globalMetrics }) => {
      this.warehouses = warehouses;
      this.stores = stores;
      this.products = products;
      this.globalMetrics = globalMetrics;
    },
    error: err => {
      console.error('Error loading data:', err);
      this.error = 'Failed to load data';
    }
  });
}
Benefits:
  • All requests fire simultaneously
  • Result emits only when all complete
  • Single error handler for all requests
  • Loading state managed with finalize()

Memory Leak Prevention

Use takeUntil() to automatically unsubscribe:
private readonly destroy$ = new Subject<void>();

ngOnInit() {
  this.service.getData()
    .pipe(takeUntil(this.destroy$))
    .subscribe(data => { /* ... */ });
}

ngOnDestroy() {
  this.destroy$.next();
  this.destroy$.complete();
}

Loading State Management

Use finalize() to ensure loading state is updated:
this.loading = true;
this.service.getData()
  .pipe(
    takeUntil(this.destroy$),
    finalize(() => { this.loading = false; })
  )
  .subscribe({ /* ... */ });
Benefit: Loading state is updated even if the request fails.

Error Handling

Consistent error handling pattern:
this.service.getData()
  .pipe(takeUntil(this.destroy$))
  .subscribe({
    next: data => {
      this.data = data;
      this.error = null;
    },
    error: err => {
      console.error('Error loading data:', err);
      this.error = 'Failed to load data';
    }
  });

State Management Approach

Component-Based State

The application uses a simple component-based state management approach:
export class AppComponent implements OnInit {
  // Application state
  globalMetrics: GlobalMetrics | null = null;
  detailedMetrics: DetailedMetrics | null = null;
  assignments: StockAssignment[] = [];
  warehouses: Warehouse[] = [];
  stores: Store[] = [];
  products: Product[] = [];
  
  // UI state
  loading = true;
  error: string | null = null;
  filters: DashboardFilters = { /* ... */ };
  activeTab: DashboardTab = 'map';
}
Characteristics:
  • State owned by parent component
  • Data flows down via @Input()
  • Events bubble up via @Output()
  • Services are stateless data providers

Data Flow Pattern

// 1. Parent loads data via services
forkJoin({
  warehouses: this.warehouseService.getAll(),
  stores: this.storeService.getAll()
}).subscribe(({ warehouses, stores }) => {
  this.warehouses = warehouses;
  this.stores = stores;
});

// 2. Parent passes to children
<app-dashboard-map
  [warehouses]="warehouses"
  [stores]="stores"
/>

// 3. Children emit events to parent
<app-dashboard-filters
  (filtersChange)="onFiltersChange($event)"
/>

// 4. Parent updates state and reloads
onFiltersChange(newFilters: DashboardFilters) {
  this.filters = newFilters;
  this.loadAssignments(this.filters);
}

Why Not NgRx?

For this application’s scope:
  • Simple state: Single parent component owns state
  • Clear data flow: Parent → children → parent
  • No complex state: No need for time-travel debugging
  • Fewer dependencies: Reduced bundle size
Consider NgRx if:
  • Multiple components need shared mutable state
  • Complex state transitions with side effects
  • Need for state snapshots and undo/redo

Environment Configuration

Development Environment

Location: src/environments/environment.ts
export const environment = {
  production: false,
  apiUrl: 'http://localhost:3000/api'
};

Production Environment

Location: src/environments/environment.prod.ts
export const environment = {
  production: true,
  apiUrl: '/api'
};

File Replacement

Configured in angular.json:
"fileReplacements": [
  {
    "replace": "src/environments/environment.ts",
    "with": "src/environments/environment.prod.ts"
  }
]

Error Handling Patterns

Service-Level Errors

Services let errors propagate to components:
getAll(): Observable<Product[]> {
  // No error handling in service
  return this.http.get<Product[]>(this.api);
}

Component-Level Errors

Components handle errors and update UI state:
this.productService.getAll()
  .pipe(takeUntil(this.destroy$))
  .subscribe({
    next: products => {
      this.products = products;
      this.error = null;
    },
    error: err => {
      console.error('Error loading products:', err);
      this.error = 'Failed to load products';
    }
  });

Global Error Handling

For application-wide error handling, implement ErrorHandler:
import { ErrorHandler, Injectable } from '@angular/core';

@Injectable()
export class GlobalErrorHandler implements ErrorHandler {
  handleError(error: Error): void {
    console.error('Global error:', error);
    // Send to error tracking service
  }
}

// In app.config.ts
export const appConfig: ApplicationConfig = {
  providers: [
    { provide: ErrorHandler, useClass: GlobalErrorHandler }
  ]
};

HTTP Interceptors

Adding Interceptors

For cross-cutting concerns (auth, logging, caching):
import { HttpInterceptorFn } from '@angular/common/http';

export const loggingInterceptor: HttpInterceptorFn = (req, next) => {
  console.log('HTTP Request:', req.method, req.url);
  return next(req);
};

// In app.config.ts
export const appConfig: ApplicationConfig = {
  providers: [
    provideHttpClient(withInterceptors([loggingInterceptor]))
  ]
};

Testing Services

Unit Test Example

import { TestBed } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { ProductService } from './product.service';

describe('ProductService', () => {
  let service: ProductService;
  let httpMock: HttpTestingController;

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [HttpClientTestingModule],
      providers: [ProductService]
    });
    
    service = TestBed.inject(ProductService);
    httpMock = TestBed.inject(HttpTestingController);
  });

  it('should fetch products', () => {
    const mockProducts = [{ id: '1', name: 'Product 1' }];
    
    service.getAll().subscribe(products => {
      expect(products).toEqual(mockProducts);
    });
    
    const req = httpMock.expectOne('http://localhost:3000/api/products');
    expect(req.request.method).toBe('GET');
    req.flush(mockProducts);
  });

  afterEach(() => {
    httpMock.verify();
  });
});

Next Steps

Build docs developers (and LLMs) love