refactor(backend): unify envelope DTOs and fix shares/bonds inconsistency

- Replace 4 duplicate meta DTOs (AuthResponseMetaDto, PortfolioResponseMetaDto,
  BrokerResponseMetaDto, ScreenerResponseMetaDto) with shared ApiResponseMeta
- Wrap shares getShare() in ApiEnvelopePayload (was raw object, unlike bonds)
- Remove unnecessary CacheModule import from securities module
- Update portfolio controller nullDataEnvelopeSchema to use shared ApiResponseMeta
- All 116 tests pass
This commit is contained in:
Sergey Krylov 2026-06-25 20:20:59 +03:00
parent 8d6410b37b
commit ccb1082535
12 changed files with 262 additions and 97 deletions

View File

@ -1,12 +1,5 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { ApiResponseMeta } from '../../../common/dto/api-response.dto';
class AuthResponseMetaDto {
@ApiProperty({ type: String, nullable: true })
cachedAt!: string | null;
@ApiProperty()
fromCache!: boolean;
}
class AuthUserDto { class AuthUserDto {
@ApiProperty() @ApiProperty()
@ -39,22 +32,22 @@ export class AuthTokenResponseDto {
@ApiProperty({ type: AuthTokenDataDto }) @ApiProperty({ type: AuthTokenDataDto })
data!: AuthTokenDataDto; data!: AuthTokenDataDto;
@ApiProperty({ type: AuthResponseMetaDto }) @ApiProperty({ type: ApiResponseMeta })
meta!: AuthResponseMetaDto; meta!: ApiResponseMeta;
} }
export class AuthProfileResponseDto { export class AuthProfileResponseDto {
@ApiProperty({ type: AuthUserDto }) @ApiProperty({ type: AuthUserDto })
data!: AuthUserDto; data!: AuthUserDto;
@ApiProperty({ type: AuthResponseMetaDto }) @ApiProperty({ type: ApiResponseMeta })
meta!: AuthResponseMetaDto; meta!: ApiResponseMeta;
} }
export class AuthLogoutResponseDto { export class AuthLogoutResponseDto {
@ApiProperty({ type: LogoutDataDto }) @ApiProperty({ type: LogoutDataDto })
data!: LogoutDataDto; data!: LogoutDataDto;
@ApiProperty({ type: AuthResponseMetaDto }) @ApiProperty({ type: ApiResponseMeta })
meta!: AuthResponseMetaDto; meta!: ApiResponseMeta;
} }

View File

@ -1,53 +1,46 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { ApiResponseMeta } from '../../../common/dto/api-response.dto';
import { AnalyticsResponseDto } from './analytics-response.dto'; import { AnalyticsResponseDto } from './analytics-response.dto';
import { PortfolioListResponseDto } from './portfolio-list-response.dto'; import { PortfolioListResponseDto } from './portfolio-list-response.dto';
import { PortfolioDetailResponseDto, PortfolioResponseDto } from './portfolio-response.dto'; import { PortfolioDetailResponseDto, PortfolioResponseDto } from './portfolio-response.dto';
import { PositionResponseDto } from './position-response.dto'; import { PositionResponseDto } from './position-response.dto';
export class PortfolioResponseMetaDto {
@ApiProperty({ type: String, nullable: true })
cachedAt!: string | null;
@ApiProperty()
fromCache!: boolean;
}
export class PortfolioListEnvelopeDto { export class PortfolioListEnvelopeDto {
@ApiProperty({ type: [PortfolioListResponseDto] }) @ApiProperty({ type: [PortfolioListResponseDto] })
data!: PortfolioListResponseDto[]; data!: PortfolioListResponseDto[];
@ApiProperty({ type: PortfolioResponseMetaDto }) @ApiProperty({ type: ApiResponseMeta })
meta!: PortfolioResponseMetaDto; meta!: ApiResponseMeta;
} }
export class PortfolioEnvelopeDto { export class PortfolioEnvelopeDto {
@ApiProperty({ type: PortfolioResponseDto }) @ApiProperty({ type: PortfolioResponseDto })
data!: PortfolioResponseDto; data!: PortfolioResponseDto;
@ApiProperty({ type: PortfolioResponseMetaDto }) @ApiProperty({ type: ApiResponseMeta })
meta!: PortfolioResponseMetaDto; meta!: ApiResponseMeta;
} }
export class PortfolioDetailEnvelopeDto { export class PortfolioDetailEnvelopeDto {
@ApiProperty({ type: PortfolioDetailResponseDto }) @ApiProperty({ type: PortfolioDetailResponseDto })
data!: PortfolioDetailResponseDto; data!: PortfolioDetailResponseDto;
@ApiProperty({ type: PortfolioResponseMetaDto }) @ApiProperty({ type: ApiResponseMeta })
meta!: PortfolioResponseMetaDto; meta!: ApiResponseMeta;
} }
export class PositionEnvelopeDto { export class PositionEnvelopeDto {
@ApiProperty({ type: PositionResponseDto }) @ApiProperty({ type: PositionResponseDto })
data!: PositionResponseDto; data!: PositionResponseDto;
@ApiProperty({ type: PortfolioResponseMetaDto }) @ApiProperty({ type: ApiResponseMeta })
meta!: PortfolioResponseMetaDto; meta!: ApiResponseMeta;
} }
export class AnalyticsEnvelopeDto { export class AnalyticsEnvelopeDto {
@ApiProperty({ type: AnalyticsResponseDto }) @ApiProperty({ type: AnalyticsResponseDto })
data!: AnalyticsResponseDto; data!: AnalyticsResponseDto;
@ApiProperty({ type: PortfolioResponseMetaDto }) @ApiProperty({ type: ApiResponseMeta })
meta!: PortfolioResponseMetaDto; meta!: ApiResponseMeta;
} }

View File

@ -13,13 +13,13 @@ import { CreatePortfolioDto } from './dto/create-portfolio.dto';
import { UpdatePortfolioDto } from './dto/update-portfolio.dto'; import { UpdatePortfolioDto } from './dto/update-portfolio.dto';
import { AddPositionDto } from './dto/add-position.dto'; import { AddPositionDto } from './dto/add-position.dto';
import { UpdatePositionDto } from './dto/update-position.dto'; import { UpdatePositionDto } from './dto/update-position.dto';
import { ApiResponseMeta } from '../../common/dto/api-response.dto';
import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { import {
AnalyticsEnvelopeDto, AnalyticsEnvelopeDto,
PortfolioDetailEnvelopeDto, PortfolioDetailEnvelopeDto,
PortfolioEnvelopeDto, PortfolioEnvelopeDto,
PortfolioListEnvelopeDto, PortfolioListEnvelopeDto,
PortfolioResponseMetaDto,
PositionEnvelopeDto, PositionEnvelopeDto,
} from './dto/portfolio-envelope.dto'; } from './dto/portfolio-envelope.dto';
@ -27,14 +27,14 @@ const nullDataEnvelopeSchema = {
type: 'object', type: 'object',
properties: { properties: {
data: { type: 'null' }, data: { type: 'null' },
meta: { $ref: getSchemaPath(PortfolioResponseMetaDto) }, meta: { $ref: getSchemaPath(ApiResponseMeta) },
}, },
required: ['data', 'meta'], required: ['data', 'meta'],
}; };
@ApiTags('Portfolios') @ApiTags('Portfolios')
@ApiBearerAuth() @ApiBearerAuth()
@ApiExtraModels(PortfolioResponseMetaDto) @ApiExtraModels(ApiResponseMeta)
@Controller('portfolios') @Controller('portfolios')
export class PortfolioController { export class PortfolioController {
constructor(private readonly portfolioService: PortfolioService) {} constructor(private readonly portfolioService: PortfolioService) {}

View File

@ -1,4 +1,5 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { ApiResponseMeta } from '../../../common/dto/api-response.dto';
export class ScreenerItemDto { export class ScreenerItemDto {
@ApiProperty({ example: 'SBER' }) @ApiProperty({ example: 'SBER' })
@ -70,18 +71,10 @@ export class ScreenerResultDto {
totalPages!: number; totalPages!: number;
} }
class ScreenerResponseMetaDto {
@ApiProperty({ type: String, nullable: true })
cachedAt!: string | null;
@ApiProperty()
fromCache!: boolean;
}
export class ScreenerResponseDto { export class ScreenerResponseDto {
@ApiProperty({ type: ScreenerResultDto }) @ApiProperty({ type: ScreenerResultDto })
data!: ScreenerResultDto; data!: ScreenerResultDto;
@ApiProperty({ type: ScreenerResponseMetaDto }) @ApiProperty({ type: ApiResponseMeta })
meta!: ScreenerResponseMetaDto; meta!: ApiResponseMeta;
} }

View File

@ -1,11 +1,9 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { CacheModule } from '../cache/cache.module';
import { SecuritiesController } from './securities.controller'; import { SecuritiesController } from './securities.controller';
import { SecuritiesService } from './securities.service'; import { SecuritiesService } from './securities.service';
import { ScreenerService } from './screener.service'; import { ScreenerService } from './screener.service';
@Module({ @Module({
imports: [CacheModule],
controllers: [SecuritiesController], controllers: [SecuritiesController],
providers: [SecuritiesService, ScreenerService], providers: [SecuritiesService, ScreenerService],
exports: [SecuritiesService], exports: [SecuritiesService],

View File

@ -83,7 +83,7 @@ describe('SharesService', () => {
'marketDataTtl', 'marketDataTtl',
); );
expect(moexClient.getShareMarketData).toHaveBeenCalledWith('SBER'); expect(moexClient.getShareMarketData).toHaveBeenCalledWith('SBER');
expect(result).toMatchObject({ expect(result.data).toMatchObject({
secid: 'SBER', secid: 'SBER',
isin: 'RU0009029540', isin: 'RU0009029540',
name: 'Сбербанк России ПАО ао', name: 'Сбербанк России ПАО ао',
@ -106,7 +106,7 @@ describe('SharesService', () => {
issueCapitalization: 6900000000000, issueCapitalization: 6900000000000,
}, },
}); });
expect(result.marketData.updatedAt).toMatch(/T18:45:00$/); expect(result.data.marketData.updatedAt).toMatch(/T18:45:00$/);
}); });
it('throws NotFoundException for non-share security', async () => { it('throws NotFoundException for non-share security', async () => {

View File

@ -23,7 +23,11 @@ export class SharesService {
throw new NotFoundException(`Share ${secid} not found`); throw new NotFoundException(`Share ${secid} not found`);
} }
const { data: marketData } = await this.cache.getOrFetch( const {
data: marketData,
fromCache,
cachedAt,
} = await this.cache.getOrFetch(
'marketdata', 'marketdata',
['shares', secid], ['shares', secid],
() => this.moexClient.getShareMarketData(secid), () => this.moexClient.getShareMarketData(secid),
@ -34,7 +38,8 @@ export class SharesService {
const change = marketData?.lastChange ?? 0; const change = marketData?.lastChange ?? 0;
const changePercent = marketData?.lastChangePrcnt ?? 0; const changePercent = marketData?.lastChangePrcnt ?? 0;
return { return new ApiEnvelopePayload(
{
secid: desc.secid, secid: desc.secid,
isin: desc.isin, isin: desc.isin,
name: desc.name, name: desc.name,
@ -59,7 +64,10 @@ export class SharesService {
? new Date().toISOString().split('T')[0] + 'T' + marketData.updateTime ? new Date().toISOString().split('T')[0] + 'T' + marketData.updateTime
: new Date().toISOString(), : new Date().toISOString(),
}, },
}; },
fromCache,
cachedAt,
);
} }
async getMarketData(secid: string) { async getMarketData(secid: string) {

View File

@ -1,4 +1,5 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { ApiResponseMeta } from '../../../common/dto/api-response.dto';
import { BrokerAccountResponseDto } from './broker-account-response.dto'; import { BrokerAccountResponseDto } from './broker-account-response.dto';
import { BrokerEventsDataDto } from './broker-events-response.dto'; import { BrokerEventsDataDto } from './broker-events-response.dto';
import { BrokerOperationSyncResponseDto } from './broker-operation-sync-query.dto'; import { BrokerOperationSyncResponseDto } from './broker-operation-sync-query.dto';
@ -7,66 +8,58 @@ import { BrokerPositionsPageResponseDto } from './broker-positions-page-response
import { BrokerPortfolioResponseDto } from './broker-portfolio-response.dto'; import { BrokerPortfolioResponseDto } from './broker-portfolio-response.dto';
import { BrokerAnalyticsDto } from './broker-analytics-response.dto'; import { BrokerAnalyticsDto } from './broker-analytics-response.dto';
export class BrokerResponseMetaDto {
@ApiProperty({ nullable: true })
cachedAt!: string | null;
@ApiProperty()
fromCache!: boolean;
}
export class BrokerAccountsEnvelopeDto { export class BrokerAccountsEnvelopeDto {
@ApiProperty({ type: [BrokerAccountResponseDto] }) @ApiProperty({ type: [BrokerAccountResponseDto] })
data!: BrokerAccountResponseDto[]; data!: BrokerAccountResponseDto[];
@ApiProperty({ type: BrokerResponseMetaDto }) @ApiProperty({ type: ApiResponseMeta })
meta!: BrokerResponseMetaDto; meta!: ApiResponseMeta;
} }
export class BrokerPortfolioEnvelopeDto { export class BrokerPortfolioEnvelopeDto {
@ApiProperty({ type: BrokerPortfolioResponseDto }) @ApiProperty({ type: BrokerPortfolioResponseDto })
data!: BrokerPortfolioResponseDto; data!: BrokerPortfolioResponseDto;
@ApiProperty({ type: BrokerResponseMetaDto }) @ApiProperty({ type: ApiResponseMeta })
meta!: BrokerResponseMetaDto; meta!: ApiResponseMeta;
} }
export class BrokerOperationsEnvelopeDto { export class BrokerOperationsEnvelopeDto {
@ApiProperty({ type: BrokerOperationsPageResponseDto }) @ApiProperty({ type: BrokerOperationsPageResponseDto })
data!: BrokerOperationsPageResponseDto; data!: BrokerOperationsPageResponseDto;
@ApiProperty({ type: BrokerResponseMetaDto }) @ApiProperty({ type: ApiResponseMeta })
meta!: BrokerResponseMetaDto; meta!: ApiResponseMeta;
} }
export class BrokerPositionsEnvelopeDto { export class BrokerPositionsEnvelopeDto {
@ApiProperty({ type: BrokerPositionsPageResponseDto }) @ApiProperty({ type: BrokerPositionsPageResponseDto })
data!: BrokerPositionsPageResponseDto; data!: BrokerPositionsPageResponseDto;
@ApiProperty({ type: BrokerResponseMetaDto }) @ApiProperty({ type: ApiResponseMeta })
meta!: BrokerResponseMetaDto; meta!: ApiResponseMeta;
} }
export class BrokerOperationSyncEnvelopeDto { export class BrokerOperationSyncEnvelopeDto {
@ApiProperty({ type: BrokerOperationSyncResponseDto }) @ApiProperty({ type: BrokerOperationSyncResponseDto })
data!: BrokerOperationSyncResponseDto; data!: BrokerOperationSyncResponseDto;
@ApiProperty({ type: BrokerResponseMetaDto }) @ApiProperty({ type: ApiResponseMeta })
meta!: BrokerResponseMetaDto; meta!: ApiResponseMeta;
} }
export class BrokerAnalyticsEnvelopeDto { export class BrokerAnalyticsEnvelopeDto {
@ApiProperty({ type: BrokerAnalyticsDto }) @ApiProperty({ type: BrokerAnalyticsDto })
data!: BrokerAnalyticsDto; data!: BrokerAnalyticsDto;
@ApiProperty({ type: BrokerResponseMetaDto }) @ApiProperty({ type: ApiResponseMeta })
meta!: BrokerResponseMetaDto; meta!: ApiResponseMeta;
} }
export class BrokerEventsEnvelopeDto { export class BrokerEventsEnvelopeDto {
@ApiProperty({ type: BrokerEventsDataDto }) @ApiProperty({ type: BrokerEventsDataDto })
data!: BrokerEventsDataDto; data!: BrokerEventsDataDto;
@ApiProperty({ type: BrokerResponseMetaDto }) @ApiProperty({ type: ApiResponseMeta })
meta!: BrokerResponseMetaDto; meta!: ApiResponseMeta;
} }

View File

@ -0,0 +1,52 @@
# Backend Architecture Improvements — Plan
## Подход
Разбиваем на итерации от самых безопасных (только DTO/косметика) к самым рискованным (сплит сервисов). Каждая итерация — отдельная задача с отдельным commit'ом.
## Итерации
### Итерация 1: Shared envelope DTO + fixes
1. Вынести `ApiResponseMeta` в `common/dto/api-response.dto.ts` как единый класс, убрать дубликаты `*ResponseMetaDto` в модулях.
2. Убрать `AuthResponseMetaDto` → заменить на `ApiResponseMeta`.
3. Поправить `shares/shares.service.ts:getShare()` — обернуть результат в `ApiEnvelopePayload`.
4. Убрать лишний импорт `CacheModule` из `securities/securities.module.ts`.
5. Убрать `PortfolioResponseMetaDto` и `BrokerResponseMetaDto` — заменить на `ApiResponseMeta`.
### Итерация 2: Screener caching + server-side пагинация
1. Добавить кеширование полного screener-датасета при пустом фильтре.
2. Ключ кеша: `'screener:full'` с TTL из `app.cache.marketDataTtl`.
### Итерация 3: Domain exception hierarchy
1. Создать `common/exceptions/` с базой `DomainException` и конкретными классами.
2. Обновить `HttpExceptionFilter` для map'инга доменных → HTTP исключений.
3. Заменить `NotFoundException` на `EntityNotFoundException` в сервисах.
### Итерация 4: Health check improvement
1. Добавить проверки: Prisma ping, MOEX `/health`, T-Bank gRPC connectivity.
2. Обновить `HealthResponseDto` с полем `checks`.
### Итерация 5: RequestLoggingMiddleware через DI
1. Перенести middleware в корректный DI-контекст через `configure()` в `AppModule`.
### Итерация 6 (отдельный эпик): MoexClientService split
1. Выделить `MoexSecuritiesClient`, `MoexMarketDataClient`, `MoexCandlesClient`.
2. Общий rate limiter + circuit breaker в shared utils.
## Data Flow
```
Controller → Service → [CacheService.getOrFetch] → [MoexClient* или TBank*] → Внешнее API
ApiEnvelopePayload<T>
TransformInterceptor → ApiResponse<T>
```
После итерации 1 все модули следуют этому потоку единообразно.

View File

@ -0,0 +1,33 @@
# Backend Architecture Improvements
## Цель
Устранить выявленные в ходе аудита архитектурные проблемы бэкенда: консистентность ответов API, качество кода модулей, обработку ошибок, тестируемость.
## Требования
1. Унифицировать формат ответов API — единый envelope DTO, используемый всеми модулями.
2. Устранить inconsistency между shares и bonds модулями.
3. Ввести иерархию доменных исключений с корректной обработкой.
4. Улучшить health check (проверка зависимостей).
5. Убрать дублирование и лишние зависимости.
6. Сохранить обратную совместимость API (поля ответов не меняются, только структура).
## Ограничения
- Не менять внешний API-контракт (формат `{ data, meta }` остаётся).
- Не рефакторить то, что не указано в требованиях.
- Каждое изменение идёт через TDD-цикл.
## Критерии приемки (Acceptance Criteria)
- [ ] Все модули используют единый shared envelope DTO из `common/dto/`
- [ ] `shares/shares.service.ts:getShare()` возвращает `ApiEnvelopePayload` как и `bonds/`
- [ ] Screener кеширует полный набор данных
- [ ] Создана иерархия доменных исключений
- [ ] `HttpExceptionFilter` корректно обрабатывает доменные исключения
- [ ] Health check проверяет Prisma, MOEX, T-Bank
- [ ] `RequestLoggingMiddleware` подключён через DI
- [ ] `securities/` не импортирует `CacheModule` напрямую
- [ ] Все тесты проходят
- [ ] `npm run build` успешен

View File

@ -0,0 +1,53 @@
# Backend Architecture Improvements — Tasks
## Итерация 1: Shared envelope DTO + consistency fixes ✅
- [x] 1.1 Создать документ аудита в `docs/research/`
- [x] 1.2 Вынести `ApiResponseMeta` в `common/dto/api-response.dto.ts` как единый shared класс
- [x] 1.3 Заменить `AuthResponseMetaDto` на `ApiResponseMeta` в `auth/`
- [x] 1.4 Заменить `PortfolioResponseMetaDto` на `ApiResponseMeta` в `portfolio/`
- [x] 1.5 Заменить `BrokerResponseMetaDto` на `ApiResponseMeta` в `tbank/`
- [x] 1.6 Поправить `shares/shares.service.ts:getShare()` — обернуть в `ApiEnvelopePayload`
- [x] 1.7 Убрать лишний импорт `CacheModule` из `securities/securities.module.ts`
- [x] 1.8 Удалить дублирующиеся envelope DTO (`ScreenerResponseMetaDto`, `AuthResponseMetaDto`, `PortfolioResponseMetaDto`, `BrokerResponseMetaDto`)
- [x] 1.9 `npm run build` успешен, 116 тестов проходят
## Итерация 2: Screener caching
- [ ] 2.1 Добавить `getFullDataset()` метод в `ScreenerService` с кешированием
- [ ] 2.2 Использовать кеш в `screen()` при пустых фильтрах
- [ ] 2.3 Тесты на кеширование screener'а
## Итерация 3: Domain exceptions
- [ ] 3.1 Создать `common/exceptions/domain.exception.ts`
- [ ] 3.2 Создать `common/exceptions/entity-not-found.exception.ts`
- [ ] 3.3 Создать `common/exceptions/moex-api.exception.ts`
- [ ] 3.4 Создать `common/exceptions/tbank-api.exception.ts`
- [ ] 3.5 Создать `common/exceptions/portfolio-access.exception.ts`
- [ ] 3.6 Обновить `HttpExceptionFilter` для доменных исключений
- [ ] 3.7 Заменить generic исключения в сервисах на доменные
- [ ] 3.8 Тесты на фильтр + исключения
## Итерация 4: Health check
- [ ] 4.1 Добавить `PrismaHealthIndicator` в `health/`
- [ ] 4.2 Добавить `MoexHealthIndicator`
- [ ] 4.3 Добавить `TBankHealthIndicator`
- [ ] 4.4 Обновить `HealthResponseDto` с `checks`
- [ ] 4.5 Тесты health module
## Итерация 5: RequestLoggingMiddleware DI
- [ ] 5.1 Переписать подключение через `configure()` в `AppModule`
- [ ] 5.2 Убрать `app.use()` из `main.ts`
## Итерация 6: MoexClientService split (отдельный эпик)
- [ ] 6.1 ADR на разделение MoexClientService
- [ ] 6.2 spec/plan/tasks отдельного эпика
- [ ] 6.3 Выделение rate limiter + circuit breaker в shared utils
- [ ] 6.4 Создание MoexSecuritiesClient
- [ ] 6.5 Создание MoexMarketDataClient
- [ ] 6.6 Создание MoexCandlesClient
- [ ] 6.7 Обновление всех потребителей

View File

@ -0,0 +1,49 @@
# Backend Architecture Audit 2026-06-25
## Что хорошо
- **Чистая модульная структура** — каждый домен в своём каталоге, NestJS-модули, DI
- **Global-модули** (`PrismaModule`, `CacheModule`, `MoexClientModule`) — не дублируются импорты
- **`CacheService.getOrFetch()`** — универсальный примитив кеширования, config-driven TTL
- **MOEX rate limiter + circuit breaker** — p-queue + ручной CB, защищают от внешнего API
- **TBank mappers layer** — proto wire type → domain type → DTO, правильная изоляция
- **Batch-оптимизация в PortfolioService**`fetchShareBatch/fetchBondBatch/fetchDividendsBatch` собирает всё одним MOEX-запросом
- **Спецификации и тесты** — 25 spec-файлов, есть TDD-подход
## Ключевые архитектурные проблемы
| # | Проблема | Где | Описание |
|---|----------|-----|----------|
| 1 | **Дублирование Envelope DTO** | Все модули | Каждый модуль переопределяет свой `*ResponseMetaDto / *EnvelopeDto`. 6+ копий одной структуры |
| 2 | **Inconsistent caching** | `shares/shares.service.ts:getShare()` (raw) vs `bonds/bonds.service.ts:getBond()` (envelope) | Разное поведение одинаковых по смыслу методов |
| 3 | **Screener — in-memory filtering** | `securities/screener.service.ts` | При пустом массиве `getShareMarketDataBatch([])` тянет **все** бумаги с MOEX и фильтрует в памяти. Не масштабируется |
| 4 | **MoexClientService — God Service** | 11 публичных методов | Один сервис делает всё: search, shares, bonds, candles, history, dividends. Нарушает SRP |
| 5 | **Отсутствие доменных исключений** | Весь код | Нет иерархии исключений (`SecurityNotFoundException`, `PortfolioAccessDeniedException`, `MoexApiException`) — только generic `NotFoundException` / `ForbiddenException` |
| 6 | **Health check — заглушка** | `health/` | Не проверяет БД, MOEX, T-Bank. Только `{ status: 'ok', timestamp, uptime }` |
| 7 | **Prisma JSON как String** | `targets`, `tags`, `payment`, `price` | JSON хранится как `String` без валидации на уровне БД. Нет типизированных JSON-полей |
| 8 | **tbank/ — перегруженный модуль** | 22 файла, 8 сервисов | Один модуль содержит gRPC клиент, мапперы, CRUD, аналитику, синхронизацию. Можно разбить |
| 9 | **Auth глобальные гарды** | `JwtAuthGuard` + `RolesGuard` как `APP_GUARD` | Неявная защита всех эндпоинтов. Приходится использовать `@Public()` для открытых |
| 10 | **RequestLoggingMiddleware** | Подключён через `.use()`, а не через `configure()` | Работает, но не идёт через DI и не является частью модуля |
## Рекомендации
### 🔴 Critical
1. **Shared envelope DTO** — вынести `ApiResponseMeta` и один generic `EnvelopeDto<T>` в `common/dto/`, убрать дублирование. Унифицировать формат ответа screener'а под общий envelope.
2. **Разделить MoexClientService** — выделить `MoexSecuritiesClient`, `MoexMarketDataClient`, `MoexCandlesClient` — каждый со своим набором методов.
3. **Убрать inconsistency shares vs bonds**`getShare()` должен возвращать `ApiEnvelopePayload` как и `getBond()`.
### 🟡 Medium
4. **Domain exception hierarchy** — создать `BaseDomainException``MoexApiException`, `SecurityNotFoundException`, `PortfolioAccessDeniedException`, `TBankApiException`. Добавить соответствующие фильтры в `HttpExceptionFilter`.
5. **Screener — server-side пагинация** — кешировать полный результат screener'а отдельным TTL.
6. **Health check прокачка** — добавить проверки Prisma (`db.ping()`), MOEX (`/health`), T-Bank gRPC connectivity.
7. **Prisma JSON → typed JSON** — использовать строки с `JSON.parse` в геттерах или перейти на отдельные таблицы.
8. **Отвязать `securities/` от прямого импорта `CacheModule`** — раз он `@Global()`, убрать лишний импорт.
### 🟢 Low / Nice to have
9. **RequestLoggingMiddleware** — перевести на `configure()` в `AppModule` для единообразия.
10. **Refactor `tbank/`** — выделить `broker-analytics` и `broker-sync` в отдельные модули, если будут расти.
11. **Swagger schema object для обёртки** — глобально настроить OpenAPI для автоматической обёртки `{ data, meta }`.
12. **Circuit breaker — вынести в декоратор** — обобщить в `@CircuitBreaker()` декоратор.