Skip to content

Архитектурные паттерны

Проект использует современные архитектурные паттерны Angular для обеспечения масштабируемости, тестируемости и удобства разработки.

Standalone Components

Angular 20 рекомендует использовать standalone компоненты вместо NgModules.

Что это даёт

  • ✅ Меньше boilerplate кода
  • ✅ Явные зависимости
  • ✅ Лучшая tree-shaking
  • ✅ Проще для новичков
  • ✅ Лучше поддержка в IDE

Пример

typescript
import { Component, inject } from "@angular/core";
import { CommonModule } from "@angular/common";
import { MatButtonModule } from "@angular/material/button";
import { UserService } from "./services/user.service";

// ✅ Standalone компонент
@Component({
	selector: "app-user-list",
	standalone: true, // Ключевой флаг
	imports: [CommonModule, MatButtonModule],
	templateUrl: "./user-list.component.html",
	styleUrl: "./user-list.component.scss"
})
export class UserListComponent {
	private readonly userService = inject(UserService);

	users$ = this.userService.getAll();
}

Структура импортов

typescript
// 1. Angular core
import { Component, inject } from "@angular/core";

// 2. Angular modules и Common
import { CommonModule } from "@angular/common";
import { ReactiveFormsModule } from "@angular/forms";

// 3. Material модули
import { MatButtonModule } from "@angular/material/button";
import { MatTableModule } from "@angular/material/table";

// 4. Local components и директивы
import { UserCardComponent } from "./components/user-card.component";

// 5. Services
import { UserService } from "./services/user.service";

@Component({
	selector: "app-users",
	standalone: true,
	imports: [CommonModule, ReactiveFormsModule, MatButtonModule, MatTableModule, UserCardComponent],
	template: `...`
})
export class UsersComponent {}

Dependency Injection через inject()

Функциональный DI API вместо конструктора.

Преимущества

  • ✅ Чище и понятнее
  • ✅ Ленивая инициализация
  • ✅ Проще для тестирования
  • ✅ Работает везде, не только в конструкторе

Пример

typescript
// ❌ Старый стиль (конструктор)
export class UserComponent {
	constructor(
		private userService: UserService,
		private router: Router,
		private logger: LoggerService
	) {}
}

// ✅ Новый стиль (inject API)
export class UserComponent {
	private readonly userService = inject(UserService);
	private readonly router = inject(Router);
	private readonly logger = inject(LoggerService);
}

// ✅ Использование в функциях
export function loadUser(id: string) {
	const userService = inject(UserService);
	return userService.getById(id);
}

State Management с @ngneat/elf

Elf — это легковесный, типобезопасный state management для Angular.

Зачем нужен state management?

  • ✅ Единый источник истины
  • ✅ Предсказуемое управление состоянием
  • ✅ Легче отладить
  • ✅ Реактивные обновления

Структура Elf store

typescript
// stores/users.store.ts
import { createStore } from "@ngneat/elf";
import { withEntities } from "@ngneat/elf-entities";

export interface IUser {
	id: string;
	name: string;
	email: string;
}

// Создаём store
export const usersStore = createStore({ name: "users" }, withEntities<IUser>());

// Выносим actions
export const updateUsersAction = createAction("[Users] Update Users");

// Reducer
updateUsersAction.subscribe(() => {
	usersStore.reset();
});

Использование в компоненте

typescript
@Component({
	selector: "app-users-list",
	standalone: true,
	imports: [CommonModule],
	template: `
		<div *ngFor="let user of users$ | async">
			{{ user.name }}
		</div>
	`
})
export class UsersListComponent {
	private readonly usersStore = inject(usersStore);

	// Получить users$ observable
	users$ = this.usersStore.pipe(selectAllEntities());
}

Reactive Forms

Typed Reactive Forms с полной типизацией.

Преимущества

  • ✅ Полная типизация формы
  • ✅ Типизированные контролы
  • ✅ Лучшая поддержка в IDE
  • ✅ Меньше ошибок runtime

Пример

typescript
import { Component, inject } from "@angular/core";
import { FormGroup, FormControl, ReactiveFormsModule } from "@angular/forms";

interface IUserForm {
	name: FormControl<string | null>;
	email: FormControl<string | null>;
	age: FormControl<number | null>;
}

@Component({
	selector: "app-user-form",
	standalone: true,
	imports: [ReactiveFormsModule],
	template: `
		<form [formGroup]="form" (ngSubmit)="submit()">
			<input formControlName="name" />
			<input formControlName="email" type="email" />
			<input formControlName="age" type="number" />
			<button type="submit">Submit</button>
		</form>
	`
})
export class UserFormComponent {
	form = new FormGroup<IUserForm>({
		name: new FormControl(null),
		email: new FormControl(null),
		age: new FormControl(null)
	});

	submit() {
		if (this.form.valid) {
			const data = this.form.getRawValue();
			// data типизирован как IUserForm
		}
	}
}

HTTP & Interceptors

Обработка HTTP с автоматическими перехватчиками.

API Service Layer

typescript
// ✅ API Service
@Injectable({ providedIn: "root" })
export class ApiService {
	private readonly http = inject(HttpClient);
	private readonly baseUrl = environment.apiUrl;

	get<T>(endpoint: string) {
		return this.http.get<IApiWrapper<T>>(`${this.baseUrl}${endpoint}`).pipe(map((response) => response.data));
	}
}

// ✅ Доменный Сервис
@Injectable({ providedIn: "root" })
export class UserService {
	private readonly api = inject(ApiService);

	getUsers() {
		return this.api.get<IUser[]>("/users");
	}
}

// ✅ Компонент
export class UsersComponent {
	private readonly userService = inject(UserService);

	users$ = this.userService.getUsers();
}

Interceptors

typescript
// ✅ Auth Interceptor
export const authInterceptor: HttpInterceptorFn = (req, next) => {
	const token = localStorage.getItem("access_token");

	if (token) {
		req = req.clone({
			setHeaders: { Authorization: `Bearer ${token}` }
		});
	}

	return next(req);
};

// ✅ Error Interceptor
export const errorInterceptor: HttpInterceptorFn = (req, next) => {
	return next(req).pipe(
		catchError((error: HttpErrorResponse) => {
			if (error.status === 401) {
				// Logout и перенаправить
			}
			return throwError(() => error);
		})
	);
};

// ✅ Используем в main.ts
bootstrapApplication(AppComponent, {
	providers: [provideHttpClient(withInterceptors([authInterceptor, errorInterceptor]))]
});

Routing & Lazy Loading

Маршрутизация с ленивой загрузкой для оптимизации.

Структура маршрутов

typescript
export const appRoutes: Routes = [
	{
		path: "auth",
		children: [
			{
				path: "login",
				canActivate: [noAuthGuard],
				loadComponent: () => import("./pages/login/login.component").then((m) => m.LoginComponent)
			}
		]
	},
	{
		path: "admin",
		canActivate: [authGuard],
		loadComponent: () => import("./pages/admin/admin.component").then((m) => m.AdminComponent),
		children: [
			{
				path: "users",
				loadComponent: () => import("./pages/users/users.component").then((m) => m.UsersComponent)
			}
		]
	}
];

Guards

Защита маршрутов с современным API.

Функциональные Guards

typescript
// ✅ Auth Guard
export const authGuard: CanActivateFn = () => {
	const authService = inject(AuthService);
	const router = inject(Router);

	if (!authService.isAuthenticated) {
		return router.createUrlTree(["/login"]);
	}

	return true;
};

// ✅ No Auth Guard
export const noAuthGuard: CanActivateFn = () => {
	const authService = inject(AuthService);
	const router = inject(Router);

	if (authService.isAuthenticated) {
		return router.createUrlTree(["/dashboard"]);
	}

	return true;
};

// ✅ Использование
const routes: Routes = [
	{
		path: "admin",
		canActivate: [authGuard],
		loadComponent: () => import("./admin.component")
	}
];

RxJS Subscription Management (@UntilDestroy)

Для автоматической отписки от Observable используем @ngneat/until-destroy.

Базовое использование

typescript
import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy";

@UntilDestroy()
@Component({
	selector: "app-my-component",
	standalone: true,
	templateUrl: "./my-component.html",
	styleUrl: "./my-component.scss"
})
export class MyComponent {
	private readonly service = inject(MyService);

	someMethod() {
		this.service
			.getData()
			.pipe(untilDestroyed(this))
			.subscribe((data) => {
				// Автоматическая отписка при destroy компонента
			});
	}
}

Когда использовать

  • ✅ При ручных подписках в методах компонента
  • ✅ В обработчиках событий, которые вызывают subscribe()
  • ❌ Не нужно с toSignal() — он сам управляет подпиской
  • ❌ Не нужно с | async в шаблонах

Пример с диалогами

typescript
@UntilDestroy()
@Component({
	/* ... */
})
export class IntegrationsComponent {
	private readonly dialogService = inject(DialogService);
	private readonly service = inject(IntegrationsService);

	createItem() {
		this.dialogService
			.onSuccess(CreateDialogComponent)
			.pipe(
				switchMap((data) => this.service.create(data)),
				untilDestroyed(this)
			)
			.subscribe();
	}
}

Signals & toSignal (Angular 17+)

Signals are a modern reactivity primitive in Angular. Use toSignal() to convert Observables to Signals for better performance and cleaner code.

Why Signals over Observables?

  • ✅ Synchronous, easier to understand
  • ✅ Better change detection (OnPush friendly)
  • ✅ Integrated with Angular's rendering
  • ✅ No | async pipe needed
typescript
export class MyComponent {
	private readonly route = inject(ActivatedRoute);
	private readonly service = inject(MyService);

	// Extract route parameter
	readonly id$ = this.route.paramMap.pipe(
		map((p) => p.get("id")),
		filter(Boolean)
	);

	// Convert to signal
	readonly id = toSignal(this.id$);

	// Load data based on id
	readonly data = toSignal(this.id$.pipe(switchMap((id) => this.service.getById(id))), { initialValue: [] });
}

In Templates

Use signals directly without async pipe:

html
<!-- Before -->
<div>{{ (data$ | async)?.name }}</div>

<!-- After -->
<div>{{ data()?.name }}</div>

Usage Guidelines

  • Always provide initialValue for toSignal() to avoid undefined
  • Use filter(Boolean) to prevent null/undefined values
  • Combine with switchMap for dependent requests
  • Add ChangeDetectionStrategy.OnPush for performance

Control Flow (Angular 17+)

Современный синтаксис вместо *ngIf, *ngFor.

typescript
// Текущий подход
<div *ngIf="user$ | async as user">
  <p>{{ user.name }}</p>
  <div *ngFor="let item of (items$ | async)">
    {{ item }}
  </div>
</div>

// Новый подход
@if (user$ | async as user) {
  <p>{{ user.name }}</p>
}

@for (item of (items$ | async); track item.id) {
  <div>{{ item }}</div>
}

@switch (status$ | async) {
  @case ("loading") {
    <p>Loading...</p>
  }
  @case ("error") {
    <p>Error!</p>
  }
  @default {
    <p>Done</p>
  }
}

Best Practices

Делайте:

  • Используйте standalone компоненты
  • Используйте inject() API
  • Ленивая загрузка маршрутов
  • Типизируйте всё
  • Используйте functional guards и interceptors
  • Используйте @UntilDestroy() + untilDestroyed(this) для подписок

Не делайте:

  • Не смешивайте NgModules и standalone
  • Не используйте конструкторы для DI
  • Не забывайте про track в *ngFor (или @for)
  • Не используйте any типы
  • Не забывайте отписываться от Observable

Следующие шаги

SaaS Admin Documentation