Skip to main content

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 APIAdmin API
Controllersuser-{domain}.controller.tsadmin-{domain}.controller.ts
Routesuser-{domain}s.routes.tsadmin-{domain}s.routes.ts
Schemasuser-{domain}s.schemas.tsadmin-{domain}s.schemas.ts
Почему префиксы, а не суффиксы?

Префиксы группируют файлы по назначению при сортировке:

admin-achievement.controller.ts
admin-achievements.routes.ts
user-achievement.controller.ts
user-achievements.routes.ts

Services — по функциональности

ФайлОтветственность
{domain}.service.tsUser API: list, getById
{domain}-admin.service.tsAdmin CRUD operations
{domain}-progress.service.tsTracking progress
{domain}-reward.service.tsClaiming rewards
{domain}-stats.service.tsStatistics & caching
{domain}-export.service.tsExport to JSON
{domain}-import.service.tsImport from JSON

Принципы

Главное правило рефакторинга

Pain-Driven Refactoring

Есть pain point → рефакторим. Нет pain point → не трогаем.

Рефакторить ради решения реальных проблем, а не ради "чистоты".

Когда НЕ рефакторить

СитуацияПочему не надо
"Для консистентности"Разные домены могут иметь разную структуру если это оправдано их сложностью
"Для правильности""Правильная" архитектура — та, которая решает задачи проекта
"Можно сделать лучше"Бесконечный процесс. Остановись когда код достаточно хорош
Код работает и понятенРаботает + понятно + есть тесты = не трогаем

Когда рефакторить

ПроблемаПризнакиДействие
God ObjectФайл 400+ lines, трудно найти метод, изменение одной фичи затрагивает много кодаРазбить по SRP
DRY violationCopy-paste в 3+ местах, баги повторяютсяВыделить общий код
Layer violationController дёргает Prisma, бизнес-логика в routesВынести в сервис (если усложняет тестирование)
Критичная логика без тестовРасчёты денег/XP, формулы с Math.*, >3 ветокСначала тесты, потом рефакторинг

Баланс важнее формальных правил

300 lines — ориентир, а не закон

Главное — логическая связность, а не количество строк. 3 контроллера по 300 lines лучше, чем 10 по 50 lines.

Когда дробить

ПризнакДействие
>500 lines И разные области ответственностиРазбить по SRP
Методы можно сгруппировать по 10-15+ штукВыделить в отдельный controller
Инъекция 8+ сервисов в конструкторРазные concerns смешаны

Когда НЕ дробить

ПризнакОставить как есть
300-500 lines с единой ответственностьюОК
Все методы работают с одной сущностьюОК
3-4 сервиса в конструктореОК
Дробление создаст 5+ микрофайлов по 50 linesOver-engineering

Правило

Цель — читаемость и maintainability, а не формальное соответствие метрикам.

Чеклист перед разделением файла

ОБЯЗАТЕЛЬНО перед любым split

Прежде чем разделять controller/service, пройди этот чеклист. Если хотя бы на 2 вопроса ответ "нет" — НЕ разделяй.

5 вопросов перед split

#ВопросЕсли "нет" → не разделяй
1Есть ли реальная боль? Сложно найти код? Частые merge conflicts? Новички не могут разобраться?Метрики не боль
2Разные области ответственности? Методы работают с разными сущностями или разными concerns?Единая ответственность = OK
38+ инъекций в конструктор? Сервис зависит от слишком многих других сервисов?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

Barrel exports запрещены

В goLoot используются явные импорты с полным путём к файлу. Barrel exports (index.ts) не создаются.

Проблема с barrel exports

Barrel exports (index.ts файлы которые реэкспортируют из других модулей) создают следующие проблемы:

ПроблемаОписание
Circular dependenciesindex.ts импортирует все модули → любой из них импортирует что-то из index → цикл
Bundle sizeTree-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-shakingBundler точно знает что импортируется
IDE navigation"Go to definition" ведёт сразу на нужный файл
Нет цикловКаждый файл импортируется напрямую
Проще code reviewИзменения изолированы в конкретных файлах

Naming convention для типов

ДоменФайл типов
achievementstypes/achievement.types.ts
bannerstypes/banner.types.ts
budgettypes/budget.types.ts
referralstypes/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

Миграция существующих доменов

Приоритет (по критичности)

ПриоритетДоменПричина
1questsМного файлов, смешанный нейминг
2cases0 тестов, много controllers
3referralsМного сервисов, нет types/index
4buffsМаленький, легко привести
5inventoryМаленький, легко привести

Шаги миграции

  1. Переименовать файлы (git mv для сохранения истории)
  2. Выделить base schemas из существующих
  3. Создать types/index.ts с экспортом типов
  4. Разделить большие сервисы по SRP
  5. Написать тесты для критичной логики
  6. Создать README.md

Связанные документы