Архитектурные паттерны
Проект использует современные архитектурные паттерны Angular для обеспечения масштабируемости, тестируемости и удобства разработки.
Standalone Components
Angular 21 рекомендует использовать standalone компоненты вместо NgModules.
Что это даёт
- ✅ Меньше boilerplate кода
- ✅ Явные зависимости
- ✅ Лучшая tree-shaking
- ✅ Проще для новичков
- ✅ Лучше поддержка в IDE
Пример
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();
}Структура импортов
// 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 вместо конструктора.
Преимущества
- ✅ Чище и понятнее
- ✅ Ленивая инициализация
- ✅ Проще для тестирования
- ✅ Работает везде, не только в конструкторе
Пример
// ❌ Старый стиль (конструктор)
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
// 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();
});Использование в компоненте
@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
Пример
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
ApiService — обгортка навколо HttpClient з автоматичною побудовою URL та конвертацією пагінації.
// ✅ API Service — повертає відповіді напряму (без auto-extraction)
@Injectable({ providedIn: "root" })
export class ApiService {
private readonly http = inject(HttpClient);
private readonly env = inject(EnvService);
// baseUrl додає `/admin` префікс до apiUrl з EnvService
private get baseUrl() {
return `${this.env.apiUrl()}/admin`;
}
get<T>(endpoint: string, params?: Record<string, string | number | boolean | undefined | null>) {
let httpParams = new HttpParams();
if (params) {
const converted = this.convertPagination(params);
for (const key of Object.keys(converted)) {
const value = converted[key];
if (value !== undefined && value !== null) {
httpParams = httpParams.set(key, String(value));
}
}
}
return this.http.get<T>(`${this.baseUrl}${endpoint}`, { params: httpParams });
}
post<T>(endpoint: string, body: unknown) {
return this.http.post<T>(`${this.baseUrl}${endpoint}`, body);
}
patch<T>(endpoint: string, body: unknown) {
return this.http.patch<T>(`${this.baseUrl}${endpoint}`, body);
}
delete<T>(endpoint: string) {
return this.http.delete<T>(`${this.baseUrl}${endpoint}`);
}
// Конвертація пагінації: page/limit -> skip/take
private convertPagination(params: Record<string, string | number | boolean | undefined | null>) {
const result = { ...params };
const page = Number(result["page"]);
const limit = Number(result["limit"]);
if (page && limit) {
result["skip"] = (page - 1) * limit;
result["take"] = limit;
} else if (limit) {
result["take"] = limit;
}
delete result["page"];
delete result["limit"];
return result;
}
}Ключові особливості:
- Без auto-extraction — відповіді повертаються напряму, без розгортання
{ success, data } - EnvService — base URL отримується через
EnvService, не зenvironment.tsнапряму /adminпрефікс —baseUrlавтоматично додає/adminдо API URL- convertPagination — автоматична конвертація
page/limitуskip/takeдля backend - Raw методи —
getRaw(),postRaw(),patchRaw(),deleteRaw()використовуютьrawBaseUrl(без/adminпрефікса)
// ✅ Доменный Сервис
@Injectable({ providedIn: "root" })
export class UserService {
private readonly api = inject(ApiService);
getUsers(page: number, limit: number) {
return this.api.get<IUser[]>("/users", { page, limit });
}
}
// ✅ Компонент
export class UsersComponent {
private readonly userService = inject(UserService);
users$ = this.userService.getUsers(1, 10);
}Interceptors
// ✅ 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
Маршрутизация с ленивой загрузкой для оптимизации.
Структура маршрутов
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
// ✅ 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.
Базовое использование
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в шаблонах
Пример с диалогами
@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
| asyncpipe needed
toSignal Pattern (Recommended)
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:
<!-- Before -->
<div>{{ (data$ | async)?.name }}</div>
<!-- After -->
<div>{{ data()?.name }}</div>Usage Guidelines
- Always provide
initialValuefortoSignal()to avoid undefined - Use
filter(Boolean)to prevent null/undefined values - Combine with
switchMapfor dependent requests - Add
ChangeDetectionStrategy.OnPushfor performance
Control Flow (Angular 17+)
Современный синтаксис вместо *ngIf, *ngFor.
// Текущий подход
<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>
}
}Sidebar List Pattern
Паттерн master-detail для страниц со списком и деталями. Используется в Billing (Refunds, Coupons, Billing Events), Workflows и других секциях.
Структура
Двухколоночный layout: sidebar-список слева, detail view справа (через <router-outlet>).
/admin/{entity} → список (sidebar)
/admin/{entity}/:id → детали (right panel)Реализация
@UntilDestroy()
@Component({
selector: "app-my-entity",
standalone: true,
imports: [SidebarListComponent, RouterOutlet],
templateUrl: "./my-entity.component.html",
changeDetection: ChangeDetectionStrategy.OnPush
})
export class MyEntityComponent {
private readonly service = inject(MyService);
private readonly router = inject(Router);
private readonly route = inject(ActivatedRoute);
readonly items = signal<IMyEntity[]>([]);
readonly selectedFilter = signal<string | null>(null);
readonly currentPage = signal(1);
constructor() {
this.loadItems();
}
onFilterClick() {
// Открыть SidebarFilterDialogComponent
}
loadItems() {
this.service
.getAll({ status: this.selectedFilter(), page: this.currentPage() })
.pipe(untilDestroyed(this))
.subscribe((response) => {
this.items.set(response.data);
this.navigateToFirstIfNeeded();
});
}
private navigateToFirstIfNeeded() {
if (!this.hasChildRoute && this.items().length) {
this.router.navigate([this.items()[0].id], { relativeTo: this.route });
}
}
}Фильтрация
Для фильтров используется SidebarFilterDialogComponent — общий диалог, принимающий массив опций и возвращающий выбранное значение.
Авто-навигация
При загрузке списка, если нет активного child route, автоматически навигирует к первому элементу.
Status Badge Pattern
Для отображения статусов используется StatusBadgeComponent с маппингом через utility-функции:
// shared/utils/status-variant.util.ts
export function getWorkflowStatusVariant(status: string) {
switch (status) {
case "RUNNING":
return "info";
case "COMPLETED":
return "success";
case "FAILED":
return "error";
default:
return "warning";
}
}<app-status-badge [label]="item.status" [variant]="getStatusVariant(item.status)" />404 Error Handling Pattern
Для страниц, загружающих сущность по ID, используется паттерн 404-обработки для отображения "не найдено".
EntityNotFoundComponent
Standalone-компонент для отображения not-found состояния:
@Component({
selector: "app-entity-not-found",
standalone: true,
imports: [MatIcon, TranslocoPipe],
templateUrl: "./entity-not-found.component.html",
styleUrl: "./entity-not-found.component.scss",
changeDetection: ChangeDetectionStrategy.OnPush
})
export class EntityNotFoundComponent {
readonly icon = input("search_off");
readonly title = input("errors.notFound.title");
readonly message = input("errors.notFound.message");
}catchNotFound Utility
RxJS-оператор для перехвата 404/400 HTTP-ошибок и установки сигнала:
export function catchNotFound(notFound: WritableSignal<boolean>) {
return <T>(source$: Observable<T>) =>
source$.pipe(
catchError((err) => {
if (err instanceof HttpErrorResponse && (err.status === 404 || err.status === 400)) {
notFound.set(true);
}
return EMPTY;
})
);
}Использование в компонентах
@Component({...})
export class EntityDetailComponent {
private readonly service = inject(MyService);
private readonly route = inject(ActivatedRoute);
readonly notFound = signal(false);
readonly id$ = this.route.paramMap.pipe(map(p => p.get("id")), filter(Boolean));
readonly entity = toSignal(
this.id$.pipe(
switchMap(id => this.service.getById(id)),
catchNotFound(this.notFound)
)
);
}В шаблоне:
@if (notFound()) {
<app-entity-not-found />
} @else if (entity()) {
<!-- Контент сущности -->
}Unit Testing
getTestingProviders()
Используйте getTestingProviders() для стандартного setup тестового окружения (HttpClient, Router, Transloco):
import { getTestingProviders } from "../../testing/testing.providers";
describe("MyComponent", () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [MyComponent],
providers: getTestingProviders()
}).compileComponents();
});
});Mock Services
Factory-функции для создания mock-сервисов:
import { createMockBroadcastsService, createMockDialogService } from "../../testing/testing.mocks";
const mockService = createMockBroadcastsService();
TestBed.overrideProvider(BroadcastsService, { useValue: mockService });Доступные mock-фабрики:
| Фабрика | Сервис |
|---|---|
createMockApiService() | ApiService |
createMockBroadcastsService() | BroadcastsService |
createMockStorageService() | StorageService |
createMockDialogService() | DialogService |
createMockToastrService() | ToastrService |
createMockWebsocketsService() | WebsocketsService |
provideMockUniversalService() | UniversalService |
provideParentRouteWithId(id) | ActivatedRoute |
Test Data
Предсозданные mock-данные для консистентных тестов:
import {
MOCK_BROADCAST,
MOCK_BROADCAST_RECIPIENT,
MOCK_EMAIL_EVENT,
createMockPaginatedResponse
} from "../../testing/testing.data";
it("should load broadcasts", () => {
mockService.getBroadcasts.and.returnValue(of(createMockPaginatedResponse([MOCK_BROADCAST], 1)));
// ...
});Best Practices
✅ Делайте:
- Используйте standalone компоненты
- Используйте
inject()API - Ленивая загрузка маршрутов
- Типизируйте всё
- Используйте functional guards и interceptors
- Используйте
@UntilDestroy()+untilDestroyed(this)для подписок
❌ Не делайте:
- Не смешивайте NgModules и standalone
- Не используйте конструкторы для DI
- Не забывайте про
trackв*ngFor(или@for) - Не используйте
anyтипы - Не забывайте отписываться от Observable
Следующие шаги
- Архитектура — общая архитектура
- Правила кода — стандарты
- Качество кода — линтинг и форматирование