1124 lines
33 KiB
Markdown

# 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<string>('app.auth.jwtSecret', ''),
jwtRefreshSecret: configService.get<string>('app.auth.jwtRefreshSecret', ''),
corsOrigins: configService.get<string[]>('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 <T extends object>(cls: new () => T, payload: Record<string, unknown>) =>
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<TBankAccountsRequest, TBankAccountsResponse>;
};
export type TBankOperationsClient = Client & {
getPortfolio: GrpcUnary<TBankPortfolioRequest, TBankPortfolioResponse>;
getPositions: GrpcUnary<TBankPositionsRequest, TBankPositionsResponse>;
getOperationsByCursor: GrpcUnary<Record<string, unknown>, TBankOperationsByCursorResponse>;
};
export type TBankInstrumentsClient = Client & {
getInstrumentBy: GrpcUnary<TBankInstrumentRequest, TBankInstrumentResponse>;
};
```
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<T> = {
data: T;
cachedAt: string;
};
```
Add a type guard inside `CacheService`:
```ts
private isCacheEntry<T>(value: unknown): value is CacheEntry<T> {
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<T>(
keyPrefix: string,
keyParts: string[],
fetchFn: () => Promise<T>,
ttlConfigKey: string,
): Promise<{ data: T; fromCache: boolean; cachedAt: string | null }> {
const key = this.buildKey(keyPrefix, ...keyParts);
const ttl = this.configService.get<number>(`app.cache.${ttlConfigKey}`, 900);
const cached = await this.get<CacheEntry<T> | T>(key);
if (cached !== undefined) {
if (this.isCacheEntry<T>(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.