Пример с AuthGuard
deprecated
Возьмём пример из feeldown-backend
С первого взгляда кажется, что класс перенасыщен, проверка метаданных, hash-сервис, парсинг slug-а, однако на самом деле этот класс делегирует задачи профильным утилитам.
- Делегирование задач
AuthGuardServiceвыполняет всю работу по валидации запроса, а наш guard просто вызываетthis.service.validateRequest(request).HashServiceвыполняет роль парсера токена и всех операций, связанных с криптографией и токенами.SlugPipeпарсит сложные slug-и.
- Разделение логики
validateMetadataвалидирует все метаданные, проверяет прав доступа, разделяем основную работу и второстепенную.validateOnlyMeотдельная большая валидацияOnlyMe-метаполя.validateUsernameпросто защита от дублирования кода.
- Использование
tryCatchThrow, чтобы избавиться от больших повторяющихсяtry-catch-блоков.
AuthGuardдемонстрирует стремление к SRP, потому что делегирует часть работы (валидацию, криптографию, парсинг slug) профильным сервисам. Однако он всё ещё нарушает принцип, смешивая аутентификацию и авторизацию, а также трансформацию данных. Его главная проблема — не размер, а размытие границ ответственности. Давайте разберём, что именно пошло не так и как это исправить.
Что плохо
validateOnlyMeможно было вынести в отдельный Guard или в отдельный сервис, чтобы Guard занимался только пропуском- Смешивание ответственности:
- AuthGuard проверяет, авторизован ли пользователь и имеет ли он доступ к конкретному ресурсу (Authentication и Authorization), лучше всего разделить его два два Guard-а.
validateMetadataвалидирует все метаданные в одном месте через условия, что нарушает open-closed принцип, лучше всего сделатьMetadataHandler, чтобы не изменять метод, а добавлять класс и изменять только массив всех метаданных.SlugPipeв Guard-е недопустим, потому что он трансформирует данные, что Guard делать не должен, лучше всего попробовать передать уже спрасированные данные или вынести парсер в отдельный сервис.
Однако
«Однако» — несмотря на выявленные недостатки, AuthGuard всё же
демонстрирует главную идею SRP: класс должен иметь только одну причину для
изменения. В текущей реализации такой причиной является управление потоком
аутентификации (пропустить или отклонить запрос). Всё, что выходит за эти
рамки (парсинг slug, криптография, проверка метаданных), уже делегировано, пусть
и не идеально.
Настоящая проблема — не в нарушении SRP как такового, а в смешении уровней
абстракции и нарушении Open/Closed Principle. Если мы выделим
OnlyMeGuard и MetadataHandlerChain, то AuthGuard станет эталонным примером
SRP: его единственная задача — скомпоновать результаты проверок и принять
решение.
Улучшенный код
only-me.guard.ts
import type { ExecutionContext } from "@nestjs/common";
import type { OnlyMeMetadata } from "@types";
import type { Request } from "express";
import {
Injectable,
CanActivate,
ExecutionContext,
ForbiddenException,
} from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { Metadata } from "@/enums";
import { OnlyMeGuardService } from "./only-me-guard.service";
@Injectable()
export class OnlyMeGuard implements CanActivate {
public constructor(
private readonly reflector: Reflector,
private readonly service: OnlyMeGuardService,
private readonly serverUserService: ServerUserService,
) {}
public async canActivate(context: ExecutionContext): Promise<boolean> {
const onlyMeMetadata = this.reflector.get<OnlyMeMetadata>(
Metadata.isOnlyMe,
context.getHandler(),
);
if (!onlyMeMetadata) {
return true;
}
const request = context.switchToHttp().getRequest<Request>();
const user = this.serverUserService.getByRequest(request);
if (!user) {
throw new ForbiddenException("User not authenticated");
}
const validated = await this.service.execute(
user,
onlyMeMetadata,
request.params,
);
if (!validated) {
throw new ForbiddenException("You are not the owner of this resource");
}
return true;
}
}
export const ONLY_ME_GUARD_PROVIDERS = [
OnlyMeGuardService,
ServerUserService,
PrismaService,
] as const;
export default OnlyMeGuard;auth.guard.ts
import type { ExecutionContext } from "@nestjs/common";
import type { Request } from "express";
import { Injectable, CanActivate } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { Metadata } from "@/enums";
import { AuthGuardService } from "./auth-guard.service";
@Injectable()
export class AuthGuard implements CanActivate {
public constructor(
private readonly reflector: Reflector,
private readonly service: AuthGuardService,
private readonly serverUserService: ServerUserService,
) {}
public async canActivate(context: ExecutionContext): Promise<boolean> {
const handler = context.getHandler();
const isPublic = this.reflector.get<boolean>(Metadata.isPublic, handler);
if (isPublic) {
return true;
}
const skipAuthGuard = this.reflector.get<boolean>(
Metadata.skipAuthGuard,
handler,
);
if (skipAuthGuard) {
return true;
}
const request = context.switchToHttp().getRequest<Request>();
const user = await this.service.execute(request);
if (!user) {
throw new UnauthorizedException("Invalid credentials");
}
this.serverUserService.setByRequest(request, user);
return true;
}
}
export const AUTH_GUARD_PROVIDERS = [
AuthGuardService,
ServerUserService,
PrismaService,
] as const;
export default AuthGuard;Примечание: здесь использовано упрощение. По-хорошему для каждой метадаты использовано свой класс, однако в данном случае это может оказаться переусложнение кода.
Здесь можно было бы применить паттерн “Цепочка обязанностей”, но для трёх условий мы пока оставляем так, чтобы не переусложнять
Пример в контроллере:
@Controller("posts")
export class PostsController {
@Public()
@Get("public")
public get() {
/* ... */
}
@UseGuards(AuthGuard)
@Post()
public post(@Body() post: CreatePostDto) {
/* ... */
}
@UseGuards(AuthGuard, OnlyMeGuard)
@Delete(ROUTES.DELETE)
public delete(@Parameter("id") id: string) {
/* ... */
}
}Альтернативный подход: цепочка обработчиков метаданных
Вместо того чтобы проверять все метаданные внутри одного метода
validateMetadata через условия, можно применить паттерн «Цепочка
обязанностей». Тогда каждый тип метаданных получает свой обработчик, и новые
типы добавляются без изменения существующего кода.
export type MetadataHandlerType = {
execute(context: ExecutionContext): Promise<boolean>;
};
@Injectable()
export class PublicHandler implements MetadataHandlerType {
public constructor(private readonly reflector: Reflector) {}
public async execute(context: ExecutionContext): Promise<boolean> {
const isPublic = this.reflector.get<boolean>(
Metadata.isPublic,
context.getHandler(),
);
return !isPublic;
}
}
@Injectable()
export class OnlyMeHandler implements MetadataHandlerType {
public constructor(
private readonly reflector: Reflector,
private readonly serverUserService: ServerUserService,
private readonly service: OnlyMeGuardService,
) {}
public async execute(context: ExecutionContext): Promise<boolean> {
const onlyMeMetadata = this.reflector.get<OnlyMeMetadata>(
Metadata.isOnlyMe,
context.getHandler(),
);
if (!onlyMeMetadata) {
return true;
}
const request = context.switchToHttp().getRequest<Request>();
const user = this.serverUserService.getByRequest(request);
if (!user) {
throw new ForbiddenException("User not authenticated");
}
const validated = await this.service.execute(
user,
onlyMeMetadata,
request.params,
);
if (!validated) {
throw new ForbiddenException("You are not the owner of this resource");
}
return true;
}
}
@Injectable()
export class MetadataHandler {
public constructor(private readonly reflector: Reflector) {}
public async execute(
handlers: MetadataHandlerType[],
context: ExecutionContext,
) {
for (const handler of handlers) {
const result = await handler.execute(context);
if (!result) {
return false;
}
}
return true;
}
}Этот подход открыт для расширения (новый тип метаданных = новый класс) и закрыт для изменения (существующие обработчики не трогаем).
Что мы сделали и зачем
Мы взяли AuthGuard, который успел обрасти ответственностью, и аккуратно
разобрали его на части. Не потому что он плохой — просто каждая деталь теперь
лежит там, где ей положено.
Вот что конкретно произошло:
-
OnlyMeGuardуехал в отдельный гараж. Вся логика сisOnlyMe, сравнениемid,usernameиslug— всё это переехало в новый класс. Теперь он занимается ровно одним делом: проверяет, имеет ли пользователь право распоряжаться этим ресурсом. И больше ничем. -
AuthGuardперестал быть универсальной сущностью. Его задача теперь кристально прозрачна:- проверить, не публичный ли маршрут (
isPublic); - не пропущен ли он специальным флагом (
skipAuthGuard); - если нет — провалидировать учётные данные через
AuthGuardService; - и положить пользователя в
ServerUserService, чтобы остальные знали, кто тут главный.
- проверить, не публичный ли маршрут (
-
ServerUserServiceстал центральным диспетчером по пользователям. Раньше каждый Guard сам решал, как достать пользователя: кто черезrequest.user, кто черезHashService. Теперь всё идёт через один сервис. Он знает, где лежит пользователь, и умеет его доставать — хоть изrequest, хоть из кэша, хоть из базы. Остальным об этом думать не надо. -
Зависимости похудели. Из
AuthGuardулетелиSlugPipeиHashService— они там больше не нужны. ИзOnlyMeGuardубрали всю муть с токенами. Каждый Guard знает ровно столько, сколько ему нужно, и не лезет в чужие дела. -
Guard-ы теперь можно смешивать как конструктор. Хотите просто проверить, что пользователь залогинен? Берите
AuthGuard. Хотите ещё и проверить, что он владеет ресурсом? ДобавьтеOnlyMeGuard. Хотите открытый маршрут? Просто повесьте@Public()— и ни одного Guard.
Что в итоге
Теперь у каждого Guard ровно одна причина, чтобы измениться:
AuthGuardпереписывают только тогда, когда меняют логику аутентификации — например, переходят с JWT на сессии или добавляют новый тип токена.OnlyMeGuardтрогают только тогда, когда меняются правила доступа — например, появляется новый способ проверки владельца (по email или по uuid).ServerUserServiceживёт своей жизнью и отвечает за то, чтобы пользователь всегда был под рукой.
Мы не просто починили нарушение SRP — мы сделали код гибким, читаемым и удобным для тестирования. И, что важно, теперь, когда через полгода придёт новый разработчик и откроет этот файл, ему не придётся гадать, что тут за что отвечает. Всё разложено по полочкам.
А главное — мы убрали смешение уровней абстракции. Теперь AuthGuard не
знает, как парсить slug, а OnlyMeGuard не знает, как работать с токенами.
Каждый занимается своим делом — и делает это хорошо.
Запомните: SRP — это не про размер класса или количество методов. Это про направление изменений. Если изменение требований к аутентификации заставляет менять код парсинга slug — значит, ответственность размазана. Если же вы можете добавить новый тип авторизации, не трогая
AuthGuard, — вы на верном пути. Главное — разделять аутентификацию и авторизацию, делегировать детали, и тогда класс будет иметь ровно одну причину для изменения.