Паттерны и соглашения
Правила написания кода и архитектурные паттерны, используемые в проекте.
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
При создании нового модуля с сущностью ОБЯЗАТЕЛЬНО:
- Создать экспорт массива сущностей в
entities/index.ts:
typescript
import { CollectionEntity } from "./collection.entity";
export { CollectionEntity };
export const COLLECTIONS_ENTITIES = [CollectionEntity];- Добавить импорт и регистрацию в
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')
`);Следующие шаги
- API Guide - Работа с API
- Deployment - Деплой приложения