Domain Structure
Стандарт структуры домена в goLoot backend. Эталон: achievements.
Структура директорий
backend/src/domains/{domain}/
├── controllers/
│ ├── user-{domain}.controller.ts # User API handlers
│ └── admin-{domain}.controller.ts # Admin API handlers
├── routes/
│ ├── user-{domain}s.routes.ts # User endpoints
│ └── admin-{domain}s.routes.ts # Admin endpoints
├── services/
│ ├── {domain}.service.ts # Core user logic
│ ├── {domain}-admin.service.ts # Admin CRUD
│ ├── {domain}-{feature}.service.ts # Feature-specific (progress, reward, etc.)
│ └── *.test.ts # Co-located tests
├── schemas/
│ ├── {domain}-base.schemas.ts # Shared schemas (DRY)
│ ├── user-{domain}s.schemas.ts # User API validation
│ └── admin-{domain}s.schemas.ts # Admin API validation
├── types/
│ └── {domain}.types.ts # Domain types (explicit name)
└── README.md # Domain documentation
Naming Conventions
Префиксы user-/admin-
| Слой | User API | Admin API |
|---|---|---|
| Controllers | user-{domain}.controller.ts | admin-{domain}.controller.ts |
| Routes | user-{domain}s.routes.ts | admin-{domain}s.routes.ts |
| Schemas | user-{domain}s.schemas.ts | admin-{domain}s.schemas.ts |
Префиксы группируют файлы по назначению при сортировке:
admin-achievement.controller.ts
admin-achievements.routes.ts
user-achievement.controller.ts
user-achievements.routes.ts
Services — по функциональности
| Файл | Ответственность |
|---|---|
{domain}.service.ts | User API: list, getById |
{domain}-admin.service.ts | Admin CRUD operations |
{domain}-progress.service.ts | Tracking progress |
{domain}-reward.service.ts | Claiming rewards |
{domain}-stats.service.ts | Statistics & caching |
{domain}-export.service.ts | Export to JSON |
{domain}-import.service.ts | Import from JSON |
Принципы
Главное правило рефакторинга
Есть pain point → рефакторим. Нет pain point → не трогаем.
Рефакторить ради решения реальных проблем, а не ради "чистоты".
Когда НЕ рефакторить
| Ситуация | Почему не надо |
|---|---|
| "Для консистентности" | Разные домены могут иметь разную структуру если это оправдано их сложностью |
| "Для правильности" | "Правильная" архитектура — та, которая решает задачи проекта |
| "Можно сделать лучше" | Бесконечный процесс. Остановись когда код достаточно хорош |
| Код работает и понятен | Работает + понятно + есть тесты = не трогаем |
Когда рефакторить
| Проблема | Признаки | Действие |
|---|---|---|
| God Object | Файл 400+ lines, трудно найти метод, изменение одной фичи затрагивает много кода | Разбить по SRP |
| DRY violation | Copy-paste в 3+ местах, баги повторяются | Выделить общий код |
| Layer violation | Controller дёргает Prisma, бизнес-логика в routes | Вынести в сервис (если усложняет тестирование) |
| Критичная логика без тестов | Расчёты денег/XP, формулы с Math.*, >3 веток | Сначала тесты, потом рефакторинг |
Баланс важнее формальных правил
Главное — логическая связность, а не количество строк. 3 контроллера по 300 lines лучше, чем 10 по 50 lines.
Когда дробить
| Признак | Действие |
|---|---|
| >500 lines И разные области ответственности | Разбить по SRP |
| Методы можно сгруппировать по 10-15+ штук | Выделить в отдельный controller |
| Инъекция 8+ сервисов в конструктор | Разные concerns смешаны |
Когда НЕ дробить
| Признак | Оставить как есть |
|---|---|
| 300-500 lines с единой ответственностью | ОК |
| Все методы работают с одной сущностью | ОК |
| 3-4 сервиса в конструкторе | ОК |
| Дробление создаст 5+ микрофайлов по 50 lines | Over-engineering |
Правило
Цель — читаемость и maintainability, а не формальное соответствие метрикам.
Чеклист перед разделением файла
Прежде чем разделять controller/service, пройди этот чеклист. Если хотя бы на 2 вопроса ответ "нет" — НЕ разделяй.
5 вопросов перед split
| # | Вопрос | Если "нет" → не разделяй |
|---|---|---|
| 1 | Есть ли реальная боль? Сложно найти код? Частые merge conflicts? Новички не могут разобраться? | Метрики не боль |
| 2 | Разные области ответственности? Методы работают с разными сущностями или разными concerns? | Единая ответственность = OK |
| 3 | 8+ инъекций в конструктор? Сервис зависит от слишком многих других сервисов? | 3-5 зависимостей = норма |
| 4 | Создаст ли split понятные границы? Каждый новый файл будет иметь чёткую, объяснимую ответственность? | Если сложно назвать — не надо |
| 5 | Не создаст ли микрофайлы? Результат НЕ будет 5+ файлов по 50-100 lines? | Микрофайлы = over-engineering |
Примеры применения
Пример 1: user-currency.service.ts (577 lines) — НЕ разделять
| Вопрос | Ответ |
|---|---|
| Реальная боль? | Нет, код понятен |
| Разные ответственности? | Нет — всё про валюты (scrap, xp, streak) |
| 8+ инъекций? | Нет, только Prisma |
| Понятные границы? | Сомнительно — "scrap-service" и "xp-service" будут дублировать паттерн |
| Микрофайлы? | Да — получится 4+ файла по 100-150 lines |
Вердикт: 4 из 5 = "нет" → НЕ разделять
Пример 2: achievement.service.ts (800 lines) — РАЗДЕЛЯТЬ
| Вопрос | Ответ |
|---|---|
| Реальная боль? | Да — сложно найти нужный метод |
| Разные ответственности? | Да — list/getById, progress tracking, claim rewards, admin CRUD |
| 8+ инъекций? | Да — 9 зависимостей |
| Понятные границы? | Да — achievement.service (user API), achievement-progress.service, achievement-reward.service, achievement-admin.service |
| Микрофайлы? | Нет — каждый файл 150-250 lines с чёткой ответственностью |
Вердикт: 5 из 5 = "да" → РАЗДЕЛЯТЬ
Быстрая проверка
Файл > 500 lines?
└─ Нет → Не трогай
└─ Да → Пройди 5 вопросов
└─ 3+ "да" → Разделяй
└─ 2+ "нет" → Не трогай
SRP (Single Responsibility)
Один сервис = одна ответственность.
# Плохо
achievement.service.ts (500+ lines, всё в одном)
# Хорошо
achievement.service.ts → User API
achievement-progress.service.ts → Increment logic
achievement-reward.service.ts → Claim logic
achievement-admin.service.ts → Admin CRUD
DRY в Schemas
Base schemas переиспользуются в user и admin:
// achievement-base.schemas.ts
export const AchievementConditionsSchema = { ... };
export const RewardSchema = { ... };
// user-achievements.schemas.ts
import { AchievementConditionsSchema } from './achievement-base.schemas';
export const GetAchievementsSchema = {
response: {
200: {
items: { $ref: AchievementConditionsSchema }
}
}
};
// admin-achievements.schemas.ts
import { AchievementConditionsSchema } from './achievement-base.schemas';
export const CreateAchievementSchema = {
body: {
conditions: AchievementConditionsSchema
}
};
Centralized Types
Типы домена хранятся в types/{domain}.types.ts с явным именем файла:
// types/achievement.types.ts
export interface AchievementWithProgress {
id: string;
title: string;
currentProgress: number;
targetProgress: number;
status: AchievementStatus;
}
export type AchievementStatus = 'LOCKED' | 'IN_PROGRESS' | 'COMPLETED' | 'CLAIMED';
export interface AchievementConditions {
categoryId?: string;
subcategory?: string;
// ...
}
Импорт типов — всегда явный путь:
// ✅ Правильно: явный путь к файлу
import { AchievementWithProgress } from '../types/achievement.types';
// ❌ Неправильно: barrel import через index.ts
import { AchievementWithProgress } from '../types';
Чеклист рефакторинга
При приведении домена к стандарту:
Файлы и нейминг
- Controllers:
user-{domain}.controller.ts,admin-{domain}.controller.ts - Routes:
user-{domain}s.routes.ts,admin-{domain}s.routes.ts - Schemas:
{domain}-base.schemas.ts,user-{domain}s.schemas.ts,admin-{domain}s.schemas.ts - Types:
types/{domain}.types.ts(явное имя, без index.ts)
Структура сервисов
- Core logic отделён от admin CRUD
- Feature-specific сервисы (progress, reward, stats)
- Каждый сервис < 300 lines
Качество
- Unit-тесты для критичной логики (расчёты, rewards)
- README.md с overview, structure, API
- Race condition защита (atomic operations)
- Custom error codes
Что НЕ нужно
-
Barrel exports (types/index.ts)— см. ADR про импорты -
Папка errors/— ошибки в сервисах -
Repository layer— см. ниже
Repository — когда нужен, когда нет
Repository — это дополнительный слой абстракции. Добавляй только при реальной необходимости.
Когда НЕ нужен (большинство случаев)
// ❌ Over-engineering: pass-through методы
class AchievementRepository {
async findById(id: string) {
return this.prisma.achievement.findUnique({ where: { id } });
}
}
// ✅ Просто: Prisma напрямую в сервисе
class AchievementService {
async getById(id: string) {
return this.prisma.achievement.findUnique({ where: { id } });
}
}
Признаки over-engineering:
- Repository методы — просто обёртки над Prisma
- Нет сложной логики в запросах
- Нет переиспользования между сервисами
Когда нужен
| Ситуация | Пример |
|---|---|
| Сложные запросы | Raw SQL, агрегации, CTE |
| Переиспользование | Один запрос в 3+ сервисах |
| Инкапсуляция | Скрыть детали join-ов от сервиса |
// ✅ Оправдано: сложный запрос переиспользуется
class BudgetRepository {
async getActivePeriodWithStats() {
return this.prisma.$queryRaw`
SELECT bp.*,
(SELECT SUM(amount) FROM craft_budget_log WHERE period_id = bp.id) as total_spent
FROM budget_periods bp
WHERE bp.is_active = true
`;
}
}
Правило
Есть pain point → добавляем repository. Нет pain point → Prisma в сервисе.
Validators — когда нужны, когда нет
Валидация обычно живёт в сервисах или Zod/JSON schemas. Отдельный validator — только при реальном дублировании.
Когда НЕ нужен (большинство случаев)
// ❌ Over-engineering: validator для одного места
class AchievementValidator {
validateConditions(conditions: unknown) { ... }
}
// ✅ Просто: валидация в сервисе или Zod schema
const AchievementConditionsSchema = z.object({
categoryId: z.string().optional(),
// ...
});
Признаки over-engineering:
- Валидация используется в 1-2 местах
- Логика простая (можно выразить в Zod)
- Нет бизнес-правил (только format check)
Когда нужен
| Ситуация | Пример |
|---|---|
| Дублирование в 3+ местах | Один и тот же код валидации копипастится |
| Сложная бизнес-логика | format + exists + active + not-self-referral |
| DB lookup в валидации | Проверка существования в БД |
// ✅ Оправдано: логика дублировалась в 6 местах
// referrals/validators/referral-code.validator.ts
export class ReferralCodeValidator {
validateFormat(code: string): boolean { ... }
async validateExists(code: string): Promise<{ valid: boolean; referralCode?: ReferralCode }> {
// format check + DB lookup + isActive check
}
async validateNotSelfReferral(code: string, userId: string): Promise<...> {
// full validation + self-referral check
}
}
Правило
Дублируется в 3+ местах → выносим в validator. Иначе — Zod schema или inline в сервисе.
Imports — явные vs barrel
В goLoot используются явные импорты с полным путём к файлу. Barrel exports (index.ts) не создаются.
Проблема с barrel exports
Barrel exports (index.ts файлы которые реэкспортируют из других модулей) создают следующие проблемы:
| Проблема | Описание |
|---|---|
| Circular dependencies | index.ts импортирует все модули → любой из них импортирует что-то из index → цикл |
| Bundle size | Tree-shaking работает хуже, т.к. bundler видит весь barrel |
| IDE navigation | "Go to definition" ведёт на barrel, а не на реальный файл |
| Hidden coupling | Непонятно какой конкретный файл используется |
| Merge conflicts | Все изменения типов затрагивают один index.ts |
Решение — явные импорты
// ✅ Правильно: явный путь к файлу
import { AchievementWithProgress } from '../types/achievement.types';
import { BannerStatsParams } from '@domains/banners/types/banner.types';
// ❌ Неправильно: barrel import
import { AchievementWithProgress } from '../types';
import { BannerStatsParams } from '@domains/banners/types';
Преимущества явных импортов
| Преимущество | Результат |
|---|---|
| Явные зависимости | Сразу видно какой файл используется |
| Лучший tree-shaking | Bundler точно знает что импортируется |
| IDE navigation | "Go to definition" ведёт сразу на нужный файл |
| Нет циклов | Каждый файл импортируется напрямую |
| Проще code review | Изменения изолированы в конкретных файлах |
Naming convention для типов
| Домен | Файл типов |
|---|---|
| achievements | types/achievement.types.ts |
| banners | types/banner.types.ts |
| budget | types/budget.types.ts |
| referrals | types/referral.types.ts |
Исключения
Публичный API домена (если домен экспортирует что-то наружу через domains/{domain}/index.ts) — допустимо, но не для внутренних types.
// domains/promo-codes/index.ts — допустимо для публичного API
export * from './services/promo-code.service';
export * from './types/promo-code.types'; // явный путь!
Пример: achievements
achievements/
├── controllers/
│ ├── user-achievement.controller.ts ✓ user- prefix
│ └── admin-achievement.controller.ts ✓ admin- prefix
├── routes/
│ ├── user-achievements.routes.ts ✓
│ └── admin-achievements.routes.ts ✓
├── services/
│ ├── achievement.service.ts ✓ User API
│ ├── achievement-admin.service.ts ✓ Admin CRUD
│ ├── achievement-progress.service.ts ✓ Progress tracking
│ ├── achievement-reward.service.ts ✓ Claim logic
│ ├── achievement-stats.service.ts ✓ Stats + cache
│ └── *.test.ts ✓ 4 test files
├── schemas/
│ ├── achievement-base.schemas.ts ✓ DRY base
│ ├── user-achievements.schemas.ts ✓
│ └── admin-achievements.schemas.ts ✓
├── types/
│ └── achievement.types.ts ✓ Explicit name
└── README.md ✓ Documentation
Миграция существующих доменов
Приоритет (по критичности)
| Приоритет | Домен | Причина |
|---|---|---|
| 1 | quests | Много файлов, смешанный нейминг |
| 2 | cases | 0 тестов, много controllers |
| 3 | referrals | Много сервисов, нет types/index |
| 4 | buffs | Маленький, легко привести |
| 5 | inventory | Маленький, легко привести |
Шаги миграции
- Переименовать файлы (git mv для сохранения истории)
- Выделить base schemas из существующих
- Создать types/index.ts с экспортом типов
- Разделить большие сервисы по SRP
- Написать тесты для критичной логики
- Создать README.md
Связанные документы
- Domain Template — шаблон документации домена
- Security Matrix — защиты по доменам