Skip to content

Паттерны и соглашения

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

CRUD Service Pattern

Все CRUD сервисы следуют единому паттерну для консистентности.

Структура сервиса

typescript
@Injectable()
export class DomainService {
	private readonly _logger = new Logger(DomainService.name);

	constructor(
		@InjectRepository(DomainEntity)
		private readonly _repository: Repository<DomainEntity>,
		private readonly _eventsService: EventsService // опционально
	) {}

	async findMany(options?: FindManyOptions<IDomain>) {
		const [data, totalCount] = await this._repository.findAndCount(options);

		return {
			data,
			totalCount,
			page: getPage(options)
		};
	}

	async findOne(options: FindOneOptions<IDomain>) {
		return this._repository.findOne(options);
	}

	async create(data: Partial<IDomain>) {
		try {
			const saved = await this._repository.save(data);
			const created = await this._repository.findOneBy({ id: saved.id });

			this._eventsService.emit(EventsEnum.ENTITY_CREATED, created);

			return created;
		} catch (error) {
			this._logger.error(error, "create");
		}
	}

	async createMany(data: Partial<IDomain>[]) {
		try {
			const saved = await this._repository.save(data);
			const savedIds = saved.map((item) => item.id);

			const created = await this._repository.find({
				where: { id: In(savedIds) }
			});

			return created;
		} catch (error) {
			this._logger.error(error, "createMany");
		}
	}

	async update(id: string, updateData: Partial<IDomain>) {
		try {
			await this._repository.update(id, updateData);
			return await this.findOne({ where: { id } });
		} catch (error) {
			this._logger.error("Cannot update entity", "update");
		}
	}

	async delete(id: string) {
		try {
			await this._repository.delete(id);
			return { success: true };
		} catch (error) {
			this._logger.error("Cannot delete entity", "delete");
		}
	}
}

Ключевые принципы

  • ✅ Используем TypeORM FindManyOptions и FindOneOptions для гибкости
  • findMany всегда возвращает paginated результаты с totalCount
  • ✅ Возвращаем entity после create/update операций
  • ✅ Try-catch для обработки ошибок с логированием
  • ✅ Relations передаются через options (никогда не захардкожены)
  • ✅ События для важных операций

Слоистая архитектура

Базовый CRUD → Бизнес-логика

Всегда начинаем с базового CRUD, затем добавляем бизнес-логику поверх:

1. users.service.ts           → CRUD по работе с пользователями
2. integrations.service.ts    → CRUD по работе с интеграциями
3. integration-manager.service.ts → Бизнес-логика поверх CRUD

Пример слоев

typescript
// Слой 1: CRUD
class IntegrationsService {
	async findOne(options: FindOneOptions<IIntegration>) {
		return this._repository.findOne(options);
	}

	async create(data: Partial<IIntegration>) {
		// Простое сохранение
	}
}

// Слой 2: Бизнес-логика
class IntegrationManagerService {
	constructor(
		private readonly _integrationsService: IntegrationsService,
		private readonly _secretsService: SecretsService,
		private readonly _handlersService: HandlersService
	) {}

	async setupIntegration(userId: string, data: SetupDto) {
		// Сложная бизнес-логика
		const integration = await this._integrationsService.create({
			userId,
			...data
		});

		// Создание secrets через Secrets API
		for (const secret of data.secrets) {
			await this._secretsService.create(integration.name, secret.key, secret.value);
		}

		await this._handlersService.createMany(integration.id, data.handlers);

		return integration;
	}
}

Entities и Interfaces

Правило использования

  • Entity классы используем ТОЛЬКО для TypeORM (в декораторах @InjectRepository)
  • Везде в коде используем интерфейсы, а не классы

✅ Правильно

typescript
// Интерфейс для типизации
export interface IUser extends IBase {
	email: string;
	password: string;
	verificationStatus: string;
}

// Entity только для TypeORM
@Entity("users")
export class UserEntity extends BaseEntity implements IUser {
	@Column()
	email: string;

	@Column()
	password: string;

	@Column({ name: "verification_status" })
	verificationStatus: string;
}

// Использование интерфейса в коде
class UsersService {
	async findOne(options: FindOneOptions<IUser>) {
		return this._repository.findOne(options);
	}

	async create(data: Partial<IUser>) {
		// ...
	}
}

❌ Неправильно

typescript
// Не используем entity класс для типизации
class UsersService {
	async findOne(options: FindOneOptions<UserEntity>) {
		// ❌
		return this._repository.findOne(options);
	}
}

TypeORM Entity Guidelines

Регистрация сущностей в DataSource

При создании нового модуля с сущностью ОБЯЗАТЕЛЬНО:

  1. Создать экспорт массива сущностей в entities/index.ts:
typescript
import { CollectionEntity } from "./collection.entity";

export { CollectionEntity };

export const COLLECTIONS_ENTITIES = [CollectionEntity];
  1. Добавить импорт и регистрацию в src/data-source.ts:
typescript
import { COLLECTIONS_ENTITIES } from "./app/collections/entities";

export const AppDataSource = new DataSource({
	entities: [
		...COLLECTIONS_ENTITIES // <-- добавить сюда
		// ... другие сущности
	]
});

Без этого TypeORM не найдет метаданные сущности при связях между модулями!

Минимализм в декораторах

typescript
// ✅ Правильно - минимум декораторов
@Entity("users")
export class UserEntity extends BaseEntity {
	@Column()
	email: string;

	@Column()
	password: string;

	@Column({ name: "verification_status" })
	verificationStatus: string;
}

// ❌ Неправильно - избыточные декораторы
@Entity("users")
export class UserEntity extends BaseEntity {
	@Column({ type: "text", nullable: false }) // ❌ избыточно
	email: string;

	@Column({ type: "text", name: "password" }) // ❌ name не нужен
	password: string;
}

Правила для декораторов

  • @Column({ name: 'user_id' }) - только для snake_case преобразования
  • ✅ По умолчанию используем text для всех строк (не указываем явно)
  • ❌ НЕ указываем { nullable: true } в @ManyToOne - FK всегда nullable
  • ❌ НЕ указываем значения по умолчанию в декораторах

Межмодульное взаимодействие

Правило

НИКОГДА не обращаемся к repository другого модуля. ВСЕГДА используем сервисы.

typescript
// ✅ Правильно - через сервис
@Injectable()
export class IntegrationsService {
	constructor(private readonly _usersService: UsersService) {}

	async createIntegration(userId: string, data: any) {
		const user = await this._usersService.findOne({
			where: { id: userId }
		});
	}
}

// ❌ Неправильно - напрямую к repository
@Injectable()
export class IntegrationsService {
	constructor(
		@InjectRepository(UserEntity)
		private readonly _userRepo: Repository<UserEntity> // ❌
	) {}
}

Relations

НИКОГДА не хардкодим relations

typescript
// ❌ Неправильно - захардкоженные relations
async getIntegrations() {
  return this._repository.find({
    relations: ["user", "handlers"] // ❌
  });
}

// ✅ Правильно - configurable relations
async getIntegrations(relations?: string[]) {
  return this._repository.find({
    relations: relations || []
  });
}

Константы и Enums

Приоритет Enums

Используем enums вместо констант для типобезопасных значений:

typescript
// ✅ Правильно - enum
export enum OrderSortEnum {
	ASC = "ASC",
	DESC = "DESC"
}

// ❌ Неправильно - константы
export const ORDER = {
	ASC: "ASC",
	DESC: "DESC"
};

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

Только для случаев без типизации TypeScript:

typescript
// ✅ Для декораторов
export const USERS_TABLE = "users";
@Entity(USERS_TABLE)

export const USER_ENDPOINTS = {
  PROFILE: "profile",
  SETTINGS: "settings"
};
@Get(USER_ENDPOINTS.PROFILE)

// ✅ Для переменных окружения
export const JWT_SECRET = "jwt_secret";

// ❌ НЕ для TypeORM полей (есть типизация)
const user = await this._repository.findOne({
  where: { email: userEmail } // ✅ Прямая строка
});

Error Handling

Try-Catch только для HTTP и DB

typescript
// ✅ Правильно - try-catch для DB
async create(data: Partial<IUser>) {
  try {
    const saved = await this._repository.save(data);
    return saved;
  } catch (error) {
    this._logger.error(error, "create");
  }
}

// ✅ Правильно - early return для логики
async processUser(userId: string) {
  const user = await this.findOne({ where: { id: userId } });

  if (!user) {
    return; // Early return
  }

  // Продолжение логики
}

// ❌ Неправильно - throw в try для catch
async badExample() {
  try {
    if (!condition) {
      throw new Error("Bad"); // ❌
    }
  } catch (error) {
    // обработка
  }
}

Возвращаемые значения

  • Сервисы возвращают результат или null
  • Сервисы НЕ выбрасывают ошибки (только логируют)
  • Контроллеры выбрасывают HTTP exceptions
typescript
// Service
async findUser(id: string) {
  try {
    return await this._repository.findOne({ where: { id } });
  } catch (error) {
    this._logger.error(error, "findUser");
    return null; // Возвращаем null, не throw
  }
}

// Controller
@Get(':id')
async getUser(@Param('id') id: string) {
  const user = await this._usersService.findUser(id);

  if (!user) {
    throw new NotFoundException('User not found'); // Throw в контроллере
  }

  return user;
}

Naming Conventions

Сущности и сервисы

typescript
// Entities - единственное число
UserEntity;
IntegrationEntity;
MessageEntity;

// Services - множественное число
usersService;
integrationsService;
messagesService;

// Repositories - множественное число
usersRepository;
integrationsRepository;

Dependency Injection

typescript
constructor(
  // Все зависимости private readonly с префиксом _
  private readonly _usersService: UsersService,
  private readonly _repository: Repository<UserEntity>
) {}

// Локальные переменные в сервисе
private readonly _logger = new Logger(UsersService.name);

Файлы

typescript
user.entity.ts;
user.interface.ts;
create - user.dto.ts;
users.service.ts;
users.controller.ts;
users.module.ts;

Минимализм

Главное правило

Чем меньше кода - тем лучше

  • ✅ Сервис должен умещаться на один экран
  • ✅ Только главная логика, без лишнего кода
  • ✅ Объемный утилитарный код → в utils/
  • ❌ НЕ создаем функции, которые вызываются один раз
  • ❌ НЕ делаем избыточную декомпозицию

Когда выносить в utils

typescript
// ✅ Выносим - используется в разных модулях
export function hashPassword(password: string) {
  return bcrypt.hash(password, 10);
}

// ❌ НЕ выносим - используется один раз
// Оставляем inline в сервисе
async create(data: CreateUserDto) {
  const hashedPassword = await bcrypt.hash(data.password, 10);
  // ...
}

Swagger Documentation

Каждый модуль должен иметь swagger документацию:

swagger/
├── [module].swagger.ts              # Основная регистрация
└── [module]-decorators.swagger.ts   # Декораторы для методов

Основной файл

typescript
export function integrationSwagger(app: INestApplication) {
	const config = new DocumentBuilder().setTitle("Integrations API").addTag("Integrations").build();

	const document = SwaggerModule.createDocument(app, config, {
		include: [IntegrationsModule]
	});

	SwaggerModule.setup("api/swagger/integrations", app, document);
}

Декораторы

typescript
export function GetIntegrationsSwagger() {
  return applyDecorators(
    ApiOperation({ summary: "Get all integrations" }),
    ApiOkResponse({ description: "Success response" })
  );
}

// Использование
@Get()
@GetIntegrationsSwagger()
async getIntegrations() {
  // ...
}

System Entities (owner_id = NULL)

Некоторые сущности могут быть системными (доступны всем пользователям) или пользовательскими.

Паттерн nullable owner_id

typescript
// Entity
@Column({ name: "owner_id", nullable: true })
ownerId!: string | null;

@ManyToOne(() => UserEntity, { onDelete: "CASCADE", nullable: true })
@JoinColumn({ name: "owner_id" })
owner!: UserEntity | null;

// Interface
interface ICollection {
	ownerId: string | null;  // NULL = системная сущность
}

Запросы с системными сущностями

typescript
import { Equal, IsNull, Or } from "typeorm";

// Получить пользовательские + системные
async findAllByUserId(userId: string) {
	return this._repository.find({
		where: { ownerId: Or(Equal(userId), IsNull()) },
		order: { ownerId: { direction: "ASC", nulls: "FIRST" }, order: "ASC" }
	});
}

// Проверить доступ (пользователь или системная)
async findOne(id: string, userId: string) {
	return this._repository.findOne({
		where: { id, ownerId: Or(Equal(userId), IsNull()) }
	});
}

Защита системных сущностей

typescript
private _isSystemEntity(entity: CollectionEntity) {
	return entity.ownerId === null;
}

async update(id: string, userId: string, dto: UpdateDto) {
	const entity = await this.findOne(id, userId);
	if (this._isSystemEntity(entity)) {
		throw new BadRequestException("Cannot modify system entity");
	}
	// ...
}

async delete(id: string, userId: string) {
	const entity = await this.findOne(id, userId);
	if (this._isSystemEntity(entity)) {
		throw new BadRequestException("Cannot delete system entity");
	}
	// ...
}

Seed системных данных в миграции

typescript
// В InitialSchema миграции
await queryRunner.query(`
	INSERT INTO collections (id, owner_id, name, description) VALUES
	('00000000-0000-0000-0000-000000000001', NULL, 'System Collection', 'Description')
`);

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