33 KiB
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
500responses. - 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
UsersServicefacade.
- Consumer of typed
apps/backend/src/modules/tbank/services/broker-operations.service.ts- Consumer of typed
OperationsServicefacade.
- Consumer of typed
apps/backend/src/modules/tbank/services/broker-portfolio.service.ts- Consumer of typed
OperationsServicefacade.
- Consumer of typed
apps/backend/src/modules/tbank/services/broker-instruments.service.ts- Consumer of typed
InstrumentsServicefacade.
- Consumer of typed
apps/backend/src/modules/cache/cache.service.ts- Owns cache helper payload format and
cachedAtmetadata.
- Owns cache helper payload format and
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.mdonly if endpoint naming or sync wording needs final alignment. - Modify
docs/features/backend-architecture-refactor/tasks.mdas 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:
git branch --show-current
rtk git status --short
Expected:
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:
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:
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:
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:
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:
} 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:
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:
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:
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:
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():
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()):
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:
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:
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:
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:
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:
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:
- Add
IsDateStringto the import list fromclass-validator. - Change
@Min(0)onquantityto@Min(1). - Change
buyDatevalidation from@IsString()to@IsDateString().
The relevant fields should become:
@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:
- Add
IsDateStringto the import list fromclass-validator. - Change
@Min(0)onquantityto@Min(1). - Change
buyDatevalidation from@IsString()to@IsDateString().
The relevant fields should become:
@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:
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:
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:
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:
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:
import type {
TBankAccountsResponse,
TBankInstrumentResponse,
TBankOperationsByCursorResponse,
TBankPortfolioResponse,
TBankPositionsResponse,
} from '../types/tbank-proto.types';
Add request and client types below GrpcServiceConstructor:
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():
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:
const usersClient = this.tbankClient.getUsersClient();
apps/backend/src/modules/tbank/services/broker-operations.service.ts:
const operationsClient = this.tbankClient.getOperationsClient();
apps/backend/src/modules/tbank/services/broker-portfolio.service.ts in both methods:
const operationsClient = this.tbankClient.getOperationsClient();
const operationsClient = this.tbankClient.getOperationsClient();
apps/backend/src/modules/tbank/services/broker-instruments.service.ts:
const instrumentsClient = this.tbankClient.getInstrumentsClient();
- Step 5: Verify no direct service-client casts remain in broker services
Run:
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:
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:
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:
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:
type CacheEntry<T> = {
data: T;
cachedAt: string;
};
Add a type guard inside CacheService:
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:
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:
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:
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:
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:
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:
- Replace diagram labels/references to
MoexClientServicewith split clients. - Remove wording that says
MoexClientModuleis global if present. - Update health summary from raw
{ status, timestamp, uptime }to envelope response withchecks.
Use this health endpoint wording:
- `GET /api/v1/health` → `{ data: { status, timestamp, uptime, checks }, meta }`
- Step 4: Update API reference
In apps/docs/docs/backend/api.md:
- Replace the raw health response example with:
{
"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 }
}
- Replace
/api/v1/broker/accounts/:accountId/operations/refreshwith/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:
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:
rg -n "MoexClientService|operations/refresh" apps/docs/docs/backend
Expected: no matches.
- Step 7: Build docs
Run:
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.mdonly if implementation reveals a spec ambiguity. -
Step 1: Run backend lint
Run:
npm run lint -w apps/backend
Expected: PASS.
- Step 2: Run full backend tests
Run:
npm run test -w apps/backend
Expected: PASS.
- Step 3: Run backend build
Run:
npm run build -w apps/backend
Expected: PASS.
- Step 4: Run docs build
Run:
npm run build -w apps/docs
Expected: PASS.
- Step 5: Confirm no direct T-Bank service-client
as anyremains in production broker services
Run:
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:
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:
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.