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 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:

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:

  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:

  @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:

  @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:

  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:

- `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:
{
  "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 }
}
  1. 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:

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.md only 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 any remains 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.