# Backend Architecture Refactor Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Improve backend architecture and safety without adding user-facing features or changing successful API response shapes. **Architecture:** Keep the refactor vertical and minimal: each task hardens one boundary, adds regression tests first, then makes the smallest production change. Configuration/security logic stays near bootstrap/config, DTO validation stays at request boundaries, T-Bank dynamic gRPC casts are centralized in `TBankClientService`, and docs are synchronized after runtime contracts are stable. **Tech Stack:** NestJS 10, TypeScript, Vitest, class-validator/class-transformer, Prisma, `@grpc/grpc-js`, cache-manager, Docusaurus docs. --- ## Scope This plan implements `docs/features/backend-architecture-refactor/spec.md` only. In scope: - Error masking for unhandled `500` responses. - Production JWT secret validation and credentialed CORS allowlist. - Portfolio position DTO validation for quantity/date inputs. - Typed T-Bank gRPC service-client boundary. - Cache metadata consistency on cache hits. - Published backend docs sync. Out of scope: - T-Bank multi-tenancy and user-owned broker connections. - Local T-Bank operations read-path. - Device sessions, refresh-token rotation, reuse detection. - Ledger/financial storage migration. - Rate limiting, CSRF, security headers. - Large service/module decomposition. --- ## File Structure ### Runtime Files - `apps/backend/src/common/filters/http-exception.filter.ts` - Owns public error response formatting and internal logging for unhandled exceptions. - `apps/backend/src/config/configuration.ts` - Owns typed application config values, including auth secrets and CORS origins. - `apps/backend/src/main.ts` - Owns Nest bootstrap wiring, including CORS options and production config assertions. - `apps/backend/src/modules/portfolio/dto/add-position.dto.ts` - Request-boundary validation for position creation. - `apps/backend/src/modules/portfolio/dto/update-position.dto.ts` - Request-boundary validation for position updates. - `apps/backend/src/modules/tbank/services/tbank-client.service.ts` - Single unsafe boundary for dynamic proto clients and typed service facade methods. - `apps/backend/src/modules/tbank/services/broker-accounts.service.ts` - Consumer of typed `UsersService` facade. - `apps/backend/src/modules/tbank/services/broker-operations.service.ts` - Consumer of typed `OperationsService` facade. - `apps/backend/src/modules/tbank/services/broker-portfolio.service.ts` - Consumer of typed `OperationsService` facade. - `apps/backend/src/modules/tbank/services/broker-instruments.service.ts` - Consumer of typed `InstrumentsService` facade. - `apps/backend/src/modules/cache/cache.service.ts` - Owns cache helper payload format and `cachedAt` metadata. ### Test Files - Create `apps/backend/src/common/filters/http-exception.filter.spec.ts`. - Create `apps/backend/src/config/backend-runtime-config.spec.ts`. - Create `apps/backend/src/modules/portfolio/dto/position.dto.spec.ts`. - Modify `apps/backend/src/modules/tbank/services/tbank-client.service.spec.ts`. - Create `apps/backend/src/modules/cache/cache.service.spec.ts`. - Modify affected broker service specs only if the typed facade requires mock updates. ### Documentation Files - Modify `apps/docs/docs/backend/moex-client.md`. - Modify `apps/docs/docs/backend/modules.md`. - Modify `apps/docs/docs/backend/api.md`. - Modify `apps/docs/docs/backend/tbank-invest.md` only if endpoint naming or sync wording needs final alignment. - Modify `docs/features/backend-architecture-refactor/tasks.md` as each task is completed. --- ## Verification Commands Use these commands from the repository root unless a task says otherwise: - Backend tests: `npm run test -w apps/backend` - Backend build: `npm run build -w apps/backend` - Backend lint: `npm run lint -w apps/backend` - Docs build after docs sync: `npm run build -w apps/docs` --- ### Task 0: Baseline Verification **Files:** none - [ ] **Step 1: Confirm branch and working tree** Run: ```bash git branch --show-current rtk git status --short ``` Expected: ```text codex/backend-architecture-refactor ``` `rtk git status --short` may show the SDD docs created before implementation. It must not show unrelated runtime changes. - [ ] **Step 2: Run backend tests before implementation** Run: ```bash npm run test -w apps/backend ``` Expected: PASS. If this fails before code changes, stop and report the baseline failure. - [ ] **Step 3: Run backend build before implementation** Run: ```bash npm run build -w apps/backend ``` Expected: PASS. If this fails before code changes, stop and report the baseline failure. --- ### Task 1: Mask Unhandled 500 Errors **Files:** - Create: `apps/backend/src/common/filters/http-exception.filter.spec.ts` - Modify: `apps/backend/src/common/filters/http-exception.filter.ts` - [ ] **Step 1: Write failing tests for public error masking** Create `apps/backend/src/common/filters/http-exception.filter.spec.ts`: ```ts import { ArgumentsHost, BadRequestException, HttpStatus } from '@nestjs/common'; import { HttpExceptionFilter } from './http-exception.filter'; describe('HttpExceptionFilter', () => { const createHost = () => { const json = vi.fn(); const status = vi.fn(() => ({ json })); const host = { switchToHttp: () => ({ getResponse: () => ({ status }), getRequest: () => ({ url: '/api/v1/test' }), }), } as unknown as ArgumentsHost; return { host, status, json }; }; it('does not expose internal Error.message for unhandled exceptions', () => { const filter = new HttpExceptionFilter(); const { host, status, json } = createHost(); filter.catch(new Error('Prisma failed at file:///secret/path'), host); expect(status).toHaveBeenCalledWith(HttpStatus.INTERNAL_SERVER_ERROR); expect(json).toHaveBeenCalledWith( expect.objectContaining({ statusCode: HttpStatus.INTERNAL_SERVER_ERROR, message: 'Internal server error', error: 'Internal Server Error', path: '/api/v1/test', }), ); expect(json.mock.calls[0][0].message).not.toContain('Prisma failed'); }); it('keeps HttpException response messages intact', () => { const filter = new HttpExceptionFilter(); const { host, status, json } = createHost(); filter.catch(new BadRequestException('Invalid request'), host); expect(status).toHaveBeenCalledWith(HttpStatus.BAD_REQUEST); expect(json).toHaveBeenCalledWith( expect.objectContaining({ statusCode: HttpStatus.BAD_REQUEST, message: 'Invalid request', error: 'Bad Request', }), ); }); }); ``` - [ ] **Step 2: Run the new test and verify it fails** Run: ```bash npm run test -w apps/backend -- src/common/filters/http-exception.filter.spec.ts ``` Expected: FAIL because the current filter returns `exception.message` for non-`HttpException` errors. - [ ] **Step 3: Implement minimal masking change** In `apps/backend/src/common/filters/http-exception.filter.ts`, keep the initial safe defaults and remove the public assignment of `exception.message` in the non-HTTP branch: ```ts } else if (exception instanceof Error) { this.logger.error(`Unhandled exception: ${exception.message}`, exception.stack); } else { this.logger.error(`Unhandled non-error exception: ${String(exception)}`); } ``` Do not change the `HttpException` branch. - [ ] **Step 4: Verify the filter test passes** Run: ```bash npm run test -w apps/backend -- src/common/filters/http-exception.filter.spec.ts ``` Expected: PASS. - [ ] **Step 5: Update task checklist** In `docs/features/backend-architecture-refactor/tasks.md`, mark Task 1 complete after verification passes. --- ### Task 2: Harden Production Runtime Config **Files:** - Create: `apps/backend/src/config/backend-runtime-config.spec.ts` - Modify: `apps/backend/src/config/configuration.ts` - Modify: `apps/backend/src/main.ts` - [ ] **Step 1: Write failing config tests** Create `apps/backend/src/config/backend-runtime-config.spec.ts`: ```ts describe('backend runtime configuration', () => { const OLD_ENV = process.env; beforeEach(() => { vi.resetModules(); process.env = { ...OLD_ENV }; delete process.env.NODE_ENV; delete process.env.JWT_SECRET; delete process.env.JWT_REFRESH_SECRET; delete process.env.BACKEND_CORS_ORIGINS; }); afterEach(() => { process.env = OLD_ENV; }); it('keeps dev auth defaults outside production', async () => { const configuration = (await import('./configuration')).default; expect(configuration().auth).toMatchObject({ jwtSecret: 'dev-jwt-secret-change-in-production', jwtRefreshSecret: 'dev-refresh-secret-change-in-production', }); }); it('parses backend CORS origins from comma-separated env', async () => { process.env.BACKEND_CORS_ORIGINS = 'https://app.example.com, http://localhost:5173 '; const configuration = (await import('./configuration')).default; expect(configuration().cors.origins).toEqual([ 'https://app.example.com', 'http://localhost:5173', ]); }); it('rejects production defaults for JWT secrets', async () => { const { assertSafeProductionConfig } = await import('../main'); expect(() => assertSafeProductionConfig({ nodeEnv: 'production', jwtSecret: 'dev-jwt-secret-change-in-production', jwtRefreshSecret: 'custom-refresh-secret', corsOrigins: ['https://app.example.com'], }), ).toThrow('JWT_SECRET must be set to a non-default value in production'); }); it('rejects production credentialed CORS without explicit origins', async () => { const { assertSafeProductionConfig } = await import('../main'); expect(() => assertSafeProductionConfig({ nodeEnv: 'production', jwtSecret: 'custom-access-secret', jwtRefreshSecret: 'custom-refresh-secret', corsOrigins: [], }), ).toThrow('BACKEND_CORS_ORIGINS must contain at least one origin in production'); }); it('allows development with reflected CORS', async () => { const { buildCorsOrigin } = await import('../main'); expect(buildCorsOrigin('development', [])).toBe(true); }); it('uses explicit production CORS origins', async () => { const { buildCorsOrigin } = await import('../main'); expect(buildCorsOrigin('production', ['https://app.example.com'])).toEqual([ 'https://app.example.com', ]); }); }); ``` - [ ] **Step 2: Run config tests and verify they fail** Run: ```bash npm run test -w apps/backend -- src/config/backend-runtime-config.spec.ts ``` Expected: FAIL because `configuration().cors` and exported bootstrap helpers do not exist yet. - [ ] **Step 3: Add CORS origins to configuration** In `apps/backend/src/config/configuration.ts`, add a small parser and `cors` config: ```ts const parseCsv = (value: string | undefined): string[] => (value ?? '') .split(',') .map((item) => item.trim()) .filter(Boolean); export default registerAs('app', () => ({ port: parseInt(process.env.PORT || '3000', 10), // existing sections stay unchanged cors: { origins: parseCsv(process.env.BACKEND_CORS_ORIGINS), }, auth: { jwtSecret: process.env.JWT_SECRET || 'dev-jwt-secret-change-in-production', jwtRefreshSecret: process.env.JWT_REFRESH_SECRET || 'dev-refresh-secret-change-in-production', jwtAccessExpires: process.env.JWT_ACCESS_EXPIRES || '15m', jwtRefreshExpires: process.env.JWT_REFRESH_EXPIRES || '7d', }, })); ``` Keep the existing `database`, `moex`, `tbank`, and `cache` sections exactly as they are. - [ ] **Step 4: Export bootstrap helpers from main** In `apps/backend/src/main.ts`, add imports and helpers above `bootstrap()`: ```ts import { ConfigService } from '@nestjs/config'; const DEV_JWT_SECRET = 'dev-jwt-secret-change-in-production'; const DEV_JWT_REFRESH_SECRET = 'dev-refresh-secret-change-in-production'; export type BackendRuntimeConfig = { nodeEnv: string; jwtSecret: string; jwtRefreshSecret: string; corsOrigins: string[]; }; export function assertSafeProductionConfig(config: BackendRuntimeConfig): void { if (config.nodeEnv !== 'production') return; if (!config.jwtSecret || config.jwtSecret === DEV_JWT_SECRET) { throw new Error('JWT_SECRET must be set to a non-default value in production'); } if (!config.jwtRefreshSecret || config.jwtRefreshSecret === DEV_JWT_REFRESH_SECRET) { throw new Error('JWT_REFRESH_SECRET must be set to a non-default value in production'); } if (config.corsOrigins.length === 0) { throw new Error('BACKEND_CORS_ORIGINS must contain at least one origin in production'); } } export function buildCorsOrigin(nodeEnv: string, corsOrigins: string[]): boolean | string[] { return nodeEnv === 'production' ? corsOrigins : true; } ``` Then update `bootstrap()` after `app.use(cookieParser())`: ```ts const configService = app.get(ConfigService); const runtimeConfig: BackendRuntimeConfig = { nodeEnv: process.env.NODE_ENV || 'development', jwtSecret: configService.get('app.auth.jwtSecret', ''), jwtRefreshSecret: configService.get('app.auth.jwtRefreshSecret', ''), corsOrigins: configService.get('app.cors.origins', []), }; assertSafeProductionConfig(runtimeConfig); app.enableCors({ origin: buildCorsOrigin(runtimeConfig.nodeEnv, runtimeConfig.corsOrigins), credentials: true, }); ``` Remove the previous line: ```ts app.enableCors({ origin: true, credentials: true }); ``` - [ ] **Step 5: Prevent bootstrap from running during import tests** At the bottom of `apps/backend/src/main.ts`, replace unconditional bootstrap with: ```ts if (process.env.NODE_ENV !== 'test') { void bootstrap(); } ``` This keeps helper imports from starting a Nest server in Vitest. - [ ] **Step 6: Verify config tests pass** Run: ```bash npm run test -w apps/backend -- src/config/backend-runtime-config.spec.ts ``` Expected: PASS. - [ ] **Step 7: Update task checklist** In `docs/features/backend-architecture-refactor/tasks.md`, mark Task 2 complete after verification passes. --- ### Task 3: Harden Portfolio Position DTO Validation **Files:** - Create: `apps/backend/src/modules/portfolio/dto/position.dto.spec.ts` - Modify: `apps/backend/src/modules/portfolio/dto/add-position.dto.ts` - Modify: `apps/backend/src/modules/portfolio/dto/update-position.dto.ts` - [ ] **Step 1: Write failing DTO validation tests** Create `apps/backend/src/modules/portfolio/dto/position.dto.spec.ts`: ```ts import 'reflect-metadata'; import { plainToInstance } from 'class-transformer'; import { validate } from 'class-validator'; import { AddPositionDto } from './add-position.dto'; import { UpdatePositionDto } from './update-position.dto'; describe('position DTO validation', () => { const validateDto = async (cls: new () => T, payload: Record) => validate(plainToInstance(cls, payload)); it('rejects zero quantity when adding a position', async () => { const errors = await validateDto(AddPositionDto, { secid: 'SBER', quantity: 0 }); expect(errors.some((error) => error.property === 'quantity')).toBe(true); }); it('rejects zero quantity when updating a position', async () => { const errors = await validateDto(UpdatePositionDto, { quantity: 0 }); expect(errors.some((error) => error.property === 'quantity')).toBe(true); }); it('rejects invalid buyDate values', async () => { const addErrors = await validateDto(AddPositionDto, { secid: 'SBER', quantity: 1, buyDate: 'not-a-date', }); const updateErrors = await validateDto(UpdatePositionDto, { buyDate: 'not-a-date' }); expect(addErrors.some((error) => error.property === 'buyDate')).toBe(true); expect(updateErrors.some((error) => error.property === 'buyDate')).toBe(true); }); it('accepts valid position payloads', async () => { await expect( validateDto(AddPositionDto, { secid: 'SBER', quantity: 1, buyDate: '2026-06-01' }), ).resolves.toHaveLength(0); await expect( validateDto(UpdatePositionDto, { quantity: 2, buyDate: '2026-06-15' }), ).resolves.toHaveLength(0); }); }); ``` - [ ] **Step 2: Run DTO tests and verify they fail** Run: ```bash npm run test -w apps/backend -- src/modules/portfolio/dto/position.dto.spec.ts ``` Expected: FAIL because `quantity: 0` and arbitrary date strings are currently accepted by DTO validation. - [ ] **Step 3: Update add-position validation** In `apps/backend/src/modules/portfolio/dto/add-position.dto.ts`: 1. Add `IsDateString` to the import list from `class-validator`. 2. Change `@Min(0)` on `quantity` to `@Min(1)`. 3. Change `buyDate` validation from `@IsString()` to `@IsDateString()`. The relevant fields should become: ```ts @ApiProperty({ example: 10 }) @IsInt() @Min(1) quantity!: number; @ApiPropertyOptional({ example: '2026-06-01' }) @IsDateString() @IsOptional() buyDate?: string; ``` - [ ] **Step 4: Update update-position validation** In `apps/backend/src/modules/portfolio/dto/update-position.dto.ts`: 1. Add `IsDateString` to the import list from `class-validator`. 2. Change `@Min(0)` on `quantity` to `@Min(1)`. 3. Change `buyDate` validation from `@IsString()` to `@IsDateString()`. The relevant fields should become: ```ts @ApiPropertyOptional({ example: 15 }) @IsInt() @Min(1) @IsOptional() quantity?: number; @ApiPropertyOptional({ example: '2026-06-15' }) @IsDateString() @IsOptional() buyDate?: string; ``` - [ ] **Step 5: Verify DTO tests pass** Run: ```bash npm run test -w apps/backend -- src/modules/portfolio/dto/position.dto.spec.ts ``` Expected: PASS. - [ ] **Step 6: Decide whether service-level zero guard stays** Keep the existing service guard in `PortfolioService.addPosition()` for defense in depth: ```ts if (dto.quantity === 0) throw new BadRequestException('Quantity must be greater than 0'); ``` Do not add new behavior to `updatePosition()` beyond DTO validation. - [ ] **Step 7: Update task checklist** In `docs/features/backend-architecture-refactor/tasks.md`, mark Task 3 complete after verification passes. --- ### Task 4: Localize T-Bank gRPC `any` Casts Behind Typed Facade **Files:** - Modify: `apps/backend/src/modules/tbank/services/tbank-client.service.ts` - Modify: `apps/backend/src/modules/tbank/services/tbank-client.service.spec.ts` - Modify: `apps/backend/src/modules/tbank/services/broker-accounts.service.ts` - Modify: `apps/backend/src/modules/tbank/services/broker-operations.service.ts` - Modify: `apps/backend/src/modules/tbank/services/broker-portfolio.service.ts` - Modify: `apps/backend/src/modules/tbank/services/broker-instruments.service.ts` - [ ] **Step 1: Write failing facade tests** In `apps/backend/src/modules/tbank/services/tbank-client.service.spec.ts`, add this test after `creates service clients from vendored proto contracts`: ```ts it('exposes typed service-client facades for broker services', () => { const service = new TBankClientService(config); expect(service.getUsersClient()).toHaveProperty('getAccounts'); expect(service.getOperationsClient()).toHaveProperty('getPortfolio'); expect(service.getOperationsClient()).toHaveProperty('getPositions'); expect(service.getOperationsClient()).toHaveProperty('getOperationsByCursor'); expect(service.getInstrumentsClient()).toHaveProperty('getInstrumentBy'); }); ``` - [ ] **Step 2: Run facade test and verify it fails** Run: ```bash npm run test -w apps/backend -- src/modules/tbank/services/tbank-client.service.spec.ts ``` Expected: FAIL because the facade methods do not exist. - [ ] **Step 3: Add service-client types and facade methods** In `apps/backend/src/modules/tbank/services/tbank-client.service.ts`, import T-Bank proto response types: ```ts import type { TBankAccountsResponse, TBankInstrumentResponse, TBankOperationsByCursorResponse, TBankPortfolioResponse, TBankPositionsResponse, } from '../types/tbank-proto.types'; ``` Add request and client types below `GrpcServiceConstructor`: ```ts type TBankAccountsRequest = { status: string }; type TBankPortfolioRequest = { accountId: string; currency: string }; type TBankPositionsRequest = { accountId: string }; type TBankInstrumentRequest = { idType: string; id: string }; export type TBankUsersClient = Client & { getAccounts: GrpcUnary; }; export type TBankOperationsClient = Client & { getPortfolio: GrpcUnary; getPositions: GrpcUnary; getOperationsByCursor: GrpcUnary, TBankOperationsByCursorResponse>; }; export type TBankInstrumentsClient = Client & { getInstrumentBy: GrpcUnary; }; ``` Add facade methods inside `TBankClientService` after `getServiceClient()`: ```ts getUsersClient(): TBankUsersClient { return this.getServiceClient('UsersService') as TBankUsersClient; } getOperationsClient(): TBankOperationsClient { return this.getServiceClient('OperationsService') as TBankOperationsClient; } getInstrumentsClient(): TBankInstrumentsClient { return this.getServiceClient('InstrumentsService') as TBankInstrumentsClient; } ``` The only remaining dynamic casts for service clients should be these facade methods. - [ ] **Step 4: Replace broker service direct casts** Update consumers: `apps/backend/src/modules/tbank/services/broker-accounts.service.ts`: ```ts const usersClient = this.tbankClient.getUsersClient(); ``` `apps/backend/src/modules/tbank/services/broker-operations.service.ts`: ```ts const operationsClient = this.tbankClient.getOperationsClient(); ``` `apps/backend/src/modules/tbank/services/broker-portfolio.service.ts` in both methods: ```ts const operationsClient = this.tbankClient.getOperationsClient(); ``` ```ts const operationsClient = this.tbankClient.getOperationsClient(); ``` `apps/backend/src/modules/tbank/services/broker-instruments.service.ts`: ```ts const instrumentsClient = this.tbankClient.getInstrumentsClient(); ``` - [ ] **Step 5: Verify no direct service-client casts remain in broker services** Run: ```bash rg "getServiceClient\('.*Service'\) as any|getServiceClient\(\".*Service\"\) as any" apps/backend/src/modules/tbank/services ``` Expected: no matches. - [ ] **Step 6: Verify T-Bank service tests pass** Run: ```bash npm run test -w apps/backend -- src/modules/tbank/services/tbank-client.service.spec.ts src/modules/tbank/services/broker-accounts.service.spec.ts src/modules/tbank/services/broker-operations.service.spec.ts src/modules/tbank/services/broker-portfolio.service.spec.ts ``` Expected: PASS. If mocks fail because specs stub `getServiceClient`, update those specs to stub the new facade method used by each service. - [ ] **Step 7: Update task checklist** In `docs/features/backend-architecture-refactor/tasks.md`, mark Task 4 complete after verification passes. --- ### Task 5: Preserve Cache `cachedAt` Metadata on Hits **Files:** - Create: `apps/backend/src/modules/cache/cache.service.spec.ts` - Modify: `apps/backend/src/modules/cache/cache.service.ts` - [ ] **Step 1: Write failing cache metadata tests** Create `apps/backend/src/modules/cache/cache.service.spec.ts`: ```ts import { ConfigService } from '@nestjs/config'; import { CacheService } from './cache.service'; describe('CacheService', () => { const configService = { get: vi.fn((_key: string, fallback?: unknown) => fallback), } as unknown as ConfigService; const createCache = () => ({ get: vi.fn(), set: vi.fn(), }); it('stores data with cachedAt metadata on cache miss', async () => { vi.useFakeTimers(); vi.setSystemTime(new Date('2026-06-25T10:00:00.000Z')); const cache = createCache(); cache.get.mockResolvedValue(undefined); const service = new CacheService(cache as never, configService); try { const result = await service.getOrFetch('prefix', ['a'], async () => ({ value: 1 }), 'ttlKey'); expect(result).toEqual({ data: { value: 1 }, fromCache: false, cachedAt: '2026-06-25T10:00:00.000Z', }); expect(cache.set).toHaveBeenCalledWith( 'prefix:a', { data: { value: 1 }, cachedAt: '2026-06-25T10:00:00.000Z' }, 900, ); } finally { vi.useRealTimers(); } }); it('returns cachedAt metadata on cache hit', async () => { const cache = createCache(); cache.get.mockResolvedValue({ data: { value: 1 }, cachedAt: '2026-06-25T10:00:00.000Z', }); const service = new CacheService(cache as never, configService); const result = await service.getOrFetch('prefix', ['a'], async () => ({ value: 2 }), 'ttlKey'); expect(result).toEqual({ data: { value: 1 }, fromCache: true, cachedAt: '2026-06-25T10:00:00.000Z', }); }); it('supports legacy raw cache values during rollout', async () => { const cache = createCache(); cache.get.mockResolvedValue({ value: 1 }); const service = new CacheService(cache as never, configService); const result = await service.getOrFetch('prefix', ['a'], async () => ({ value: 2 }), 'ttlKey'); expect(result).toEqual({ data: { value: 1 }, fromCache: true, cachedAt: null }); }); }); ``` - [ ] **Step 2: Run cache tests and verify they fail** Run: ```bash npm run test -w apps/backend -- src/modules/cache/cache.service.spec.ts ``` Expected: FAIL because cache hits currently return `cachedAt: null` and misses store raw values. - [ ] **Step 3: Implement cache entry wrapper** In `apps/backend/src/modules/cache/cache.service.ts`, add a private type near imports: ```ts type CacheEntry = { data: T; cachedAt: string; }; ``` Add a type guard inside `CacheService`: ```ts private isCacheEntry(value: unknown): value is CacheEntry { return ( typeof value === 'object' && value !== null && 'data' in value && 'cachedAt' in value && typeof (value as { cachedAt?: unknown }).cachedAt === 'string' ); } ``` Replace `getOrFetch()` implementation with: ```ts async getOrFetch( keyPrefix: string, keyParts: string[], fetchFn: () => Promise, ttlConfigKey: string, ): Promise<{ data: T; fromCache: boolean; cachedAt: string | null }> { const key = this.buildKey(keyPrefix, ...keyParts); const ttl = this.configService.get(`app.cache.${ttlConfigKey}`, 900); const cached = await this.get | T>(key); if (cached !== undefined) { if (this.isCacheEntry(cached)) { return { data: cached.data, fromCache: true, cachedAt: cached.cachedAt }; } return { data: cached as T, fromCache: true, cachedAt: null }; } const data = await fetchFn(); const cachedAt = new Date().toISOString(); await this.set(key, { data, cachedAt }, ttl); return { data, fromCache: false, cachedAt }; } ``` - [ ] **Step 4: Verify cache tests pass** Run: ```bash npm run test -w apps/backend -- src/modules/cache/cache.service.spec.ts ``` Expected: PASS. - [ ] **Step 5: Run service tests that use cache wrapper** Run: ```bash npm run test -w apps/backend -- src/modules/shares/shares.service.spec.ts src/modules/bonds/bonds.service.spec.ts src/modules/candles/candles.service.spec.ts src/modules/securities/screener.service.spec.ts src/modules/tbank/services/broker-accounts.service.spec.ts src/modules/tbank/services/broker-portfolio.service.spec.ts src/modules/tbank/services/broker-analytics.service.spec.ts ``` Expected: PASS. - [ ] **Step 6: Update task checklist** In `docs/features/backend-architecture-refactor/tasks.md`, mark Task 5 complete after verification passes. --- ### Task 6: Synchronize Published Backend Docs **Files:** - Modify: `apps/docs/docs/backend/moex-client.md` - Modify: `apps/docs/docs/backend/modules.md` - Modify: `apps/docs/docs/backend/api.md` - Modify: `apps/docs/docs/backend/tbank-invest.md` - [ ] **Step 1: Confirm stale docs signals before editing** Run: ```bash rg -n "MoexClientService|operations/refresh|\"status\": \"ok\"" apps/docs/docs/backend ``` Expected: matches in `moex-client.md`, `modules.md`, and `api.md`. - [ ] **Step 2: Update MOEX client docs** In `apps/docs/docs/backend/moex-client.md`, replace the overview and method table so the primary abstraction is: ```md MOEX integration is split into a shared HTTP infrastructure client and focused domain clients under `apps/backend/src/modules/moex-client/`: - `MoexHttpClient` — request queue, rate limiting, circuit breaker, ISS JSON parsing. - `MoexSecuritiesClient` — security search and descriptions. - `MoexMarketDataClient` — share/bond market data and batch position enrichment. - `MoexCandlesClient` — candle history. - `MoexHistoryClient` — share and bond history. - `MoexDividendsClient` — dividend calendar. ``` Keep the rate limiting, circuit breaker, response parsing, and ISS table format sections, but make them describe `MoexHttpClient` instead of removed `MoexClientService`. - [ ] **Step 3: Update backend module docs** In `apps/docs/docs/backend/modules.md`: 1. Replace diagram labels/references to `MoexClientService` with split clients. 2. Remove wording that says `MoexClientModule` is global if present. 3. Update health summary from raw `{ status, timestamp, uptime }` to envelope response with `checks`. Use this health endpoint wording: ```md - `GET /api/v1/health` → `{ data: { status, timestamp, uptime, checks }, meta }` ``` - [ ] **Step 4: Update API reference** In `apps/docs/docs/backend/api.md`: 1. Replace the raw health response example with: ```json { "data": { "status": "ok", "timestamp": "2026-06-25T12:00:00.000Z", "uptime": 1234.56, "checks": [ { "name": "database", "status": "ok" }, { "name": "moex", "status": "ok" }, { "name": "tbank", "status": "ok" } ] }, "meta": { "fromCache": false, "cachedAt": null } } ``` 2. Replace `/api/v1/broker/accounts/:accountId/operations/refresh` with `/api/v1/broker/accounts/:accountId/operations/sync`. - [ ] **Step 5: Align T-Bank docs if needed** In `apps/docs/docs/backend/tbank-invest.md`, ensure it consistently names the sync endpoint as: ```md POST /api/v1/broker/accounts/:accountId/operations/sync ``` Do not introduce local read-path claims; this feature does not implement local-first reads. - [ ] **Step 6: Verify stale docs signals are gone** Run: ```bash rg -n "MoexClientService|operations/refresh" apps/docs/docs/backend ``` Expected: no matches. - [ ] **Step 7: Build docs** Run: ```bash npm run build -w apps/docs ``` Expected: PASS. - [ ] **Step 8: Update task checklist** In `docs/features/backend-architecture-refactor/tasks.md`, mark Task 6 complete after verification passes. --- ### Task 7: Final Quality Gate **Files:** - Modify: `docs/features/backend-architecture-refactor/tasks.md` - Modify: `docs/features/backend-architecture-refactor/spec.md` only if implementation reveals a spec ambiguity. - [ ] **Step 1: Run backend lint** Run: ```bash npm run lint -w apps/backend ``` Expected: PASS. - [ ] **Step 2: Run full backend tests** Run: ```bash npm run test -w apps/backend ``` Expected: PASS. - [ ] **Step 3: Run backend build** Run: ```bash npm run build -w apps/backend ``` Expected: PASS. - [ ] **Step 4: Run docs build** Run: ```bash npm run build -w apps/docs ``` Expected: PASS. - [ ] **Step 5: Confirm no direct T-Bank service-client `as any` remains in production broker services** Run: ```bash rg -n "getServiceClient\('.*Service'\) as any|getServiceClient\(\".*Service\"\) as any" apps/backend/src/modules/tbank/services --glob '!*.spec.ts' ``` Expected: no matches. - [ ] **Step 6: Confirm docs no longer reference stale backend contracts** Run: ```bash rg -n "MoexClientService|operations/refresh" apps/docs/docs/backend ``` Expected: no matches. - [ ] **Step 7: Mark final checklist complete** In `docs/features/backend-architecture-refactor/tasks.md`, mark Task 7 complete and add the final verification commands with PASS results. - [ ] **Step 8: Inspect final diff** Run: ```bash rtk git status --short rtk git diff ``` Expected: only files related to this SDD feature and implementation are changed. --- ## Plan Self-Review - Spec requirement `Error masking`: covered by Task 1. - Spec requirement `Production configuration hardening`: covered by Task 2. - Spec requirement `DTO validation hardening`: covered by Task 3. - Spec requirement `T-Bank gRPC typed boundary`: covered by Task 4. - Spec requirement `Cache metadata consistency`: covered by Task 5. - Spec requirement `Backend documentation sync`: covered by Task 6. - Final quality gates and acceptance checks: covered by Task 7. - Out-of-scope items are not implemented by any task.