Angular Development Guide

July 26, 2026 · View on GitHub

Patterns and conventions for the Angular workspace (Angular 22, zoneless, Signal Forms, spartan/ui).

Workspace Structure

The Angular frontend uses a monorepo workspace with multiple projects:

src/Client/Logistics.Angular/
├── angular.json                    # Workspace configuration
├── package.json                    # Shared dependencies
├── tsconfig.json                   # Base TypeScript config
├── ng-openapi-gen.json             # API client generation
├── projects/
│   ├── shared/                     # @logistics/shared library
│   │   ├── src/lib/
│   │   │   ├── api/                # Generated API client
│   │   │   ├── errors/             # Error handling
│   │   │   ├── services/           # Shared services (toast, cache)
│   │   │   ├── interceptors/       # HTTP interceptors
│   │   │   └── ui/                 # Design system (@logistics/shared/ui)
│   │   │       ├── primitives/     # Vendored spartan/ui Helm components
│   │   │       ├── form/           # ui-form-field + the ui-*-field controls
│   │   │       ├── table/          # ui-data-table
│   │   │       ├── layout/         # ui-stack, ui-grid, ui-page-header, ...
│   │   │       ├── containers/     # ui-card, ui-dashboard-card, ...
│   │   │       ├── badges/         # ui-badge, ui-status-badge, ...
│   │   │       ├── display/        # ui-alert, ui-typography, ui-avatar, ...
│   │   │       ├── overlay/        # ui-dialog, ui-popover, ui-toaster, ...
│   │   │       ├── status/         # ui-empty-state, ui-error-state, ...
│   │   │       ├── disclosure/     # ui-tabs, ui-accordion, ui-stepper, ...
│   │   │       └── icons/          # UI_ICONS record (icons.ts) + ui-icon
│   │   └── ng-package.json
│   ├── admin-portal/               # Admin Portal (super admin)
│   │   └── src/app/
│   │       ├── core/               # Admin auth
│   │       └── pages/              # Tenant, subscription management
│   ├── tms-portal/                 # TMS Portal (dispatchers/managers)
│   │   └── src/app/
│   │       ├── core/               # App-specific auth, services
│   │       ├── shared/             # TMS-specific components
│   │       └── pages/              # Feature pages
│   ├── customer-portal/            # Customer Portal (self-service)
│   │   └── src/app/
│   │       ├── core/               # Customer auth
│   │       ├── shared/             # Customer components
│   │       └── pages/              # Customer features
│   └── website/                    # Marketing Website (SSR)
│       └── src/app/
│           ├── layout/             # Header, footer
│           └── pages/              # Home, about, blog
└── scripts/

Projects

ProjectPrefixPortDescription
@logistics/sharedui-N/AShared library (API, services, pipes)
@logistics/admin-portaladm-7002Super admin management
@logistics/tms-portalapp-7003Internal TMS for dispatchers
@logistics/customer-portalcp-7004Customer self-service portal
@logistics/websiteweb-7005Marketing website (SSR)

TMS Portal Structure

projects/tms-portal/src/app/
├── core/                    # Singleton services
│   ├── auth/                # Authentication (OIDC)
│   ├── interceptors/        # HTTP interceptors
│   └── services/            # Core services (messaging, etc.)
├── shared/                  # Shared components
│   ├── components/          # Reusable UI components
│   └── pipes/               # Custom pipes
├── pages/                   # Feature pages
│   ├── dashboard/
│   ├── loads/
│   ├── customers/
│   ├── messages/            # Real-time messaging
│   ├── inspections/         # Vehicle condition reports
│   └── ...
├── app.component.ts
├── app.config.ts
└── app.routes.ts

Key Patterns

Standalone Components Only

No NgModules. All components are standalone:

@Component({
  selector: "app-load-list",
  imports: [CommonModule, TableModule, ButtonModule],
  templateUrl: "./load-list.component.html",
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class LoadListComponent {}

Signal-Based State

Use signals for reactive state:

@Component({ ... })
export class LoadListComponent {
  private loadService = inject(LoadService);

  // State signals
  loads = signal<Load[]>([]);
  loading = signal(false);
  error = signal<string | null>(null);

  // Computed values
  activeLoads = computed(() =>
    this.loads().filter(l => l.status === 'Active')
  );

  async loadData() {
    this.loading.set(true);
    try {
      const result = await this.loadService.getLoads();
      this.loads.set(result.data);
    } catch (e) {
      this.error.set('Failed to load data');
    } finally {
      this.loading.set(false);
    }
  }
}

Input/Output Functions

Use input() and output() instead of decorators:

@Component({ ... })
export class LoadCardComponent {
  // Inputs
  load = input.required<Load>();
  showActions = input(true);

  // Outputs
  edit = output<Load>();
  delete = output<string>();

  onEdit() {
    this.edit.emit(this.load());
  }

  onDelete() {
    this.delete.emit(this.load().id);
  }
}

Native Control Flow

Use @if, @for, @switch instead of structural directives:

<!-- Good -->
@if (loading()) {
<ui-loading-skeleton />
} @else if (error()) {
<ui-error-state [message]="error()" />
} @else { @for (load of loads(); track load.id) {
<app-load-card [load]="load" />
} @empty {
<p>No loads found</p>
} }

<!-- Avoid -->
<ui-loading-skeleton *ngIf="loading"></ui-loading-skeleton>
<div *ngFor="let load of loads">...</div>

Inject Function

Use inject() instead of constructor injection:

@Component({ ... })
export class LoadListComponent {
  private loadService = inject(LoadService);
  private router = inject(Router);
  private toastService = inject(ToastService);

  // No constructor needed for DI
}

OnPush Change Detection

Always use OnPush:

@Component({
  changeDetection: ChangeDetectionStrategy.OnPush
})

Signal Forms

The workspace is 100% Signal Forms (@angular/forms/signals). There is no ReactiveFormsModule and no formControlName anywhere - do not introduce either. A form is a model signal() plus a form() schema:

@Component({
  imports: [FormRoot, FormField, UiFormField, UiTextField, UiNumberField, ValidatedForm],
  // ...
})
export class LoadFormComponent {
  private readonly api = inject(Api);

  protected readonly model = signal({ customerId: "", origin: "", destination: "", weight: 0 });

  protected readonly form = form(
    this.model,
    (p) => {
      required(p.customerId, { message: "Customer is required." });
      required(p.origin, { message: "Origin is required." });
      maxLength(p.origin, 200, { message: "Origin must be 200 characters or fewer." });
      required(p.destination, { message: "Destination is required." });
      maxLength(p.destination, 200, { message: "Destination must be 200 characters or fewer." });
      min(p.weight, 0, { message: "Weight cannot be negative." });
    },
    {
      submission: {
        action: async () => {
          await this.api.invoke(createLoad, { body: this.model() });
          return undefined; // or ValidationError[] to attach server errors to fields
        },
      },
    },
  );
}
<form [formRoot]="form">
  <ui-form-field label="Origin" for="origin" [required]="true">
    <ui-text-field id="origin" [formField]="form.origin" />
  </ui-form-field>

  <button type="submit" [disabled]="form().submitting()">Save</button>
</form>

<form [formRoot]> runs submission.action on submit: it marks the whole tree touched first (so inline errors reveal themselves), skips the action while invalid, and drives form().submitting(). No markAllAsTouched(), no if (form.invalid) return guard, no (ngSubmit). Never gate the submit button on form().invalid() - keep it clickable so the ValidatedForm directive can focus the first invalid control.

Full API and recipes: .claude/skills/signal-forms-reference/SKILL.md.

API Client

Regenerating Client

After API changes, regenerate the TypeScript client:

bun run gen:api

This reads the OpenAPI spec from the running API and generates typed services in projects/shared/src/lib/api/.

Using API Services

Import API services from the shared library:

import { LoadsApiService } from '@logistics/shared/api';

@Component({ ... })
export class LoadListComponent {
  private api = inject(LoadsApiService);

  async loadData() {
    const response = await firstValueFrom(
      this.api.getLoads({ page: 1, pageSize: 20 })
    );

    if (response.isSuccess) {
      this.loads.set(response.data.items);
    }
  }
}

Shared Library Imports

// API services
import { CustomersApiService, LoadsApiService } from "@logistics/shared/api";
// Error handling
import { ErrorHandlerService } from "@logistics/shared/errors";
// Common services
import { HttpCacheService, ToastService } from "@logistics/shared/services";

Common Commands

# Development servers
bun run start:admin       # Admin Portal on https://localhost:7002
bun run start:tms         # TMS Portal on https://localhost:7003
bun run start:customer    # Customer Portal on https://localhost:7004
bun run start:website     # Website on http://localhost:7005

# Build
bun run build:shared      # Build shared library
bun run build:tms         # Build TMS Portal
bun run build:customer    # Build Customer Portal
bun run build:all         # Build all projects

# Linting & formatting
bun run lint
bun run format

# Generate API client (outputs to shared library)
bun run gen:api

UI Components

The UI library is spartan/ui: Helm components vendored in-repo under projects/shared/src/lib/ui/primitives/ on top of @spartan-ng/brain.

PrimeNG is gone - fully removed (no dependency, no import, no p-* markup, no theme preset). Never reintroduce a p-* component or a primeng/* import: the ESLint no-restricted-imports rule in eslint.config.js fails lint on any primeng/primeicons/@primeuix/* import. Browse /ui-lab (a lazy dev route in tms-portal) to see every ui-* component rendered in light and dark before hand-rolling anything new.

Feature code does not touch the Helm primitives directly. It uses the shared ui-* components from @logistics/shared/ui, which live in projects/shared/src/lib/ui/:

import {
  Alert, // ui-alert, ui-badge, ui-status-badge, ui-typography, ui-theme-toggle
  EmptyState, // ui-empty-state, ui-error-state, ui-loading-skeleton, ui-data-container
  Icon, // ui-icon - the only way to render an icon
  Stack, // ui-stack, ui-grid, ui-container, ui-surface, ui-toolbar, ui-page-header
  UiDataTable, // ui-data-table, with <th uiSortHeader="Field">
  UiFormField, // label / hint / error wrapper
  UiTextField, // ...and select, multiselect, number, currency, unit, date,
} from "@logistics/shared/ui";
AreaComponents
Formsui-form-field, ui-text-field, ui-textarea-field, ui-select-field, ui-multiselect-field, ui-number-field, ui-currency-field, ui-unit-field, ui-date-field, ui-date-range-picker, ui-checkbox-field, ui-toggle-field, ui-password-field, ui-autocomplete-field, ui-search-field, ui-phone-field, ui-address-form, ui-language-picker
Tableui-data-table (+ <th uiSortHeader="Field">), ui-table-paginator
Actionui-button, ui-toggle-group, ui-theme-toggle
Badgesui-badge, ui-status-badge, ui-count-badge, ui-overlay-badge
Displayui-typography, ui-avatar, ui-timeline, ui-chart, ui-money-with-tax, ui-pdf-viewer, ui-alert
Overlayui-dialog, ui-confirm-dialog, ui-confirm-delete-dialog, ui-popover, [uiTooltip], ui-drawer, ui-menu, ui-lightbox, ui-toaster, ui-cookie-banner
Statusui-spinner, ui-skeleton, ui-loading-skeleton, ui-progress, ui-empty-state, ui-error-state, ui-data-container
Layoutui-divider, ui-container, ui-grid, ui-stack, ui-surface, ui-toolbar
Containersui-card, ui-dashboard-card, ui-feature-row, ui-page-header
Disclosureui-tabs, ui-accordion, ui-stepper, ui-collapsible

Check projects/shared/src/lib/ui/ for the full set before hand-rolling a new component.

Icons are <ui-icon name="..."/> only - never a raw <ng-icon> in feature code. name is the typed IconName union (a key of the UI_ICONS record in projects/shared/src/lib/ui/icons/icons.ts); an unknown static name is a compile error. To add a glyph, import its @ng-icons/lucide export and add one entry to UI_ICONS. Toasts and confirmation dialogs go through ToastService from @logistics/shared - never inject MessageService or ConfirmationService.

Routing

Routes are defined in app.routes.ts:

export const routes: Routes = [
  {
    path: "",
    component: LayoutComponent,
    canActivate: [authGuard],
    children: [
      { path: "dashboard", component: DashboardComponent },
      {
        path: "loads",
        loadComponent: () =>
          import("./features/loads/load-list.component").then((m) => m.LoadListComponent),
      },
    ],
  },
  { path: "login", component: LoginComponent },
];

Authentication

Auth is handled via OIDC:

// auth.service.ts
@Injectable({ providedIn: "root" })
export class AuthService {
  private user = signal<User | null>(null);

  isAuthenticated = computed(() => this.user() !== null);

  async login(username: string, password: string) {
    const token = await this.getToken(username, password);
    this.storeToken(token);
    this.user.set(this.decodeToken(token));
  }

  logout() {
    this.clearToken();
    this.user.set(null);
  }
}

Testing

# Run tests
bun run test

# Watch mode
bun run test:watch

Next Steps