NestJS 集合、Provider 生命週期與執行時行為檢視
Reviewing NestJS collections, provider lifetimes and runtime behavior
文章檢視 NestJS 11.2.7 的集合操作、依賴注入、資源生命週期、記錄與快取等執行時契約,並附上本機編譯檢查的教學程式碼。範例涵蓋以 Map 保留首個重複項及請求順序、輸入與輸出上限、Provider 身分與快取期限測試。作者提醒,效能判斷要計入索引建構、實際命中率及首次使用成本;本機檢查只驗證教學行為,並非生產環境基準測試。
Replacing repeated find calls with a Map can change which duplicate wins. Start with that contract, then check provider identity, resource readiness and cache behavior before making performance claims.
Example scope: NestJS 11.2.7, TypeScript 5.9.3, Node.js 20+; local checks used Node.js 24.11.0. Nest examples assume
reflect-metadata,experimentalDecoratorsandemitDecoratorMetadata. Controllers, authentication and persistence are omitted unless shown. The article snippets were compiled and checked locally; they are teaching examples, not production benchmarks.
Preserve the collection contract
The response must keep request order, repeated IDs and the first matching catalog row.
Before: search the catalog for every ID
// collections.ts
export type Card = { id: string; title: string };
export function selectCardsBefore(
rows: readonly Card[], ids: readonly string[],
): Card[] {
const result: Card[] = [];
for (const id of ids) {
const row = rows.find(candidate => candidate.id === id);
if (row) result.push({ id: row.id, title: row.title });
}
return result;
}
After: build one request-local index
// collections.ts, continued
export function selectCardsIndexed(
rows: readonly Card[], ids: readonly string[],
): Card[] {
const firstById = new Map<string, Card>();
for (const row of rows) {
if (!firstById.has(row.id)) firstById.set(row.id, row);
}
const result: Card[] = [];
for (const id of ids) {
const row = firstById.get(id);
if (row) result.push({ id: row.id, title: row.title });
}
return result;
}
The has check preserves the first duplicate. Iterating over ids preserves order and repetitions. Explicit { id, title } projection returns fresh public objects and excludes extra fields.
Test those details before accepting the refactor:
// collections.test.ts
import assert from 'node:assert/strict';
import { selectCardsBefore, selectCardsIndexed } from './collections';
const rows = Object.freeze([
Object.freeze({ id: 'a', title: 'First', internalToken: 'private' }),
Object.freeze({ id: 'a', title: 'Later', internalToken: 'private' }),
]);
const ids = Object.freeze(['missing', 'a', 'a']);
const expected = [{ id: 'a', title: 'First' }, { id: 'a', title: 'First' }];
for (const select of [selectCardsBefore, selectCardsIndexed]) {
const result = select(rows, ids);
assert.deepEqual(result, expected);
assert.notStrictEqual(result[0], rows[0]);
assert.notStrictEqual(result[0], result[1]);
}
| Need | Starting point | Tradeoff |
|---|---|---|
| One lookup in a small array | find |
No index setup |
| A yes/no answer | some |
Stops at the first match |
| Repeated lookups | Map |
Setup work and extra memory |
| Repeated membership checks |
Set of stable IDs |
Object equality still uses identity |
A Set can filter IDs while keeping their order and repetitions. Authorization must supply the allowed IDs from a trusted server-side policy:
// collections.ts, continued
export function permittedIds(ids: readonly string[], allowedIds: readonly string[]) {
const allowed = new Set(allowedIds);
return ids.filter(id => allowed.has(id));
}
These selectors assume validated, bounded inputs. Local push does not mutate caller-owned arrays; repeatedly spreading a growing accumulator can create quadratic copying. Measure index construction and realistic hit rates before claiming a speed improvement.
Bound the resource at its boundary
Bound request bytes, input items, database reads and output separately.
JSON parser: a 4 KiB request body.
Input validation: at most 32 IDs, each 1–40 characters.
Data adapter: filtering, projection and a query limit.
Output construction: an integer limit from 1 to 32.
Validate JSON as unknown
This standalone parser rejects extra fields and malformed values. Use the project's existing validation tools when available; TypeScript types alone do not validate JSON.
// selection.ts
import { BadRequestException } from '@nestjs/common';
export function parseSelection(value: unknown): { ids: string[]; limit: number } {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new BadRequestException('Invalid selection');
}
const input = value as Record<string, unknown>;
const { ids, limit } = input;
if (Object.keys(input).some(key => key !== 'ids' && key !== 'limit') ||
!Array.isArray(ids) || ids.length > 32 ||
!ids.every(id => typeof id === 'string' && id.length >= 1 && id.length <= 40) ||
typeof limit !== 'number' || !Number.isInteger(limit) || limit < 1 || limit > 32) {
throw new BadRequestException('Invalid selection');
}
return { ids: [...ids], limit };
}
Apply the byte bound before JSON parsing completes. This factory accepts your existing root module and initializes an Express application without opening a listening port:
// http-app.ts
import { Type } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
export async function createHttpApp(root: Type<unknown>) {
const app = await NestFactory.create<NestExpressApplication>(root, {
bodyParser: false, logger: false,
});
app.useBodyParser('json', { limit: '4kb' });
await app.init();
return app;
}
logger: false keeps this teaching harness quiet. Configure application logging separately. See Nest's body-parser guidance and validation pipeline.
The bounded collection reference also checks output limits inside the selector, so direct service calls cannot bypass them. An early break limits result construction; it cannot undo a full catalog load or index build.
Make DI identity and lifetime visible
Inject a narrow port with a shared runtime token
An interface disappears at runtime. Nest resolves the shared Symbol; useExisting aliases the registered adapter.
// stock.ts
import { Inject, Injectable, Module } from '@nestjs/common';
export interface StockReader { available(sku: string): Promise<number> }
export const STOCK_READER = Symbol('inventory.stock-reader');
@Injectable()
export class LocalStock implements StockReader {
async available(sku: string): Promise<number> {
return sku === 'book' ? 3 : 0; // Teaching adapter; no database.
}
}
@Injectable()
export class CanReserve {
constructor(@Inject(STOCK_READER) private readonly stock: StockReader) {}
async execute(sku: string, quantity: number): Promise<boolean> {
if (!sku || !Number.isSafeInteger(quantity) || quantity <= 0) {
throw new Error('Invalid reservation');
}
const count = await this.stock.available(sku);
if (!Number.isSafeInteger(count) || count < 0) throw new Error('Invalid stock');
return count >= quantity;
}
}
@Module({
providers: [LocalStock, { provide: STOCK_READER, useExisting: LocalStock }, CanReserve],
exports: [STOCK_READER, CanReserve],
})
export class StockModule {}
Import the same token everywhere: another Symbol('inventory.stock-reader') has a different identity. A separate useClass registration can create another adapter instance. See custom providers.
CanReserve only checks availability; it does not reserve stock atomically. A substitute adapter must preserve errors and valid counts. Converting a dependency failure to zero changes behavior despite a matching TypeScript signature.
Choose the instance owner
| Scope | Instance ownership |
|---|---|
| Default singleton | Shared across consumers of that provider registration |
| Transient | Separate per consumer instance; repeated method calls reuse it |
| Request | Per request context; scope can propagate to consumers |
Request isolation adds construction work. Test sharing with Nest's real container and explicit context IDs; a tenant-shaped object alone proves no authorization. The versioned scope tests demonstrate these boundaries.
Treat startup, shutdown and logs as contracts
Reject invalid configuration before readiness
A factory can validate configuration during provider construction:
// settings.ts
export function parsePort(raw = '3000'): number {
if (!/^[0-9]+$/.test(raw)) throw new Error('Invalid PORT');
const port = Number(raw);
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('Invalid PORT');
return port;
}
export const SETTINGS = Symbol('settings');
export const settingsProvider = {
provide: SETTINGS,
useFactory: () => Object.freeze({ port: parsePort(process.env.PORT) }),
};
Register settingsProvider in your module. Prefer the existing ConfigModule where installed; this factory omits secret loading and configuration precedence.
Await connection and cleanup
Register ManagedResource and provide RESOURCE with your adapter:
// resource.ts
import { Inject, Injectable } from '@nestjs/common';
export interface Resource { connect(): Promise<void>; close(): Promise<void> }
export const RESOURCE = Symbol('resource');
@Injectable()
export class ManagedResource {
ready = false;
constructor(@Inject(RESOURCE) private readonly resource: Resource) {}
async onModuleInit(): Promise<void> {
await this.resource.connect();
}
onApplicationBootstrap(): void { this.ready = true; }
onModuleDestroy(): void { this.ready = false; }
async onApplicationShutdown(): Promise<void> {
await this.resource.close();
}
}
Cleanup runs in onApplicationShutdown, after Nest closes its connections. Nest recognizes these method names without lifecycle interfaces; the interfaces can add compile-time checks. Lifecycle documentation describes the order.
Production boundary: adapters need deadlines, cancellation and partial-connection cleanup. Coordinate readiness, ingress removal and bounded draining in the deployment.
app.close()tests do not prove OS signal handling or live-request draining. Enable shutdown hooks for the deployment's signals; an immediateprocess.exit()can interrupt cleanup.
Project log fields explicitly
Type annotations do not strip extra properties. Build the event you intend to emit:
// completion.ts
export function completionEvent(input: {
requestId: string; status: number; durationMs: number;
}): string {
if (typeof input.requestId !== 'string' || !/^[a-zA-Z0-9-]{1,64}$/.test(input.requestId) ||
!Number.isInteger(input.status) || input.status < 100 || input.status > 599 ||
!Number.isFinite(input.durationMs) || input.durationMs < 0) {
throw new Error('Invalid completion event');
}
return JSON.stringify({
event: 'request.completed', requestId: input.requestId,
status: input.status, durationMs: input.durationMs,
});
}
This prevents extra authorization/body fields from reaching the event. The sink still needs backpressure, failure and retention policies. This helper does not configure or test Nest's bootstrap logger.
Measure the cost an optimization moves
Share pending cache loads and define expiry
This cache holds one public immutable string, at most 1024 UTF-8 bytes, for one second. Injecting the clock makes expiry tests deterministic.
// summary-cache.ts
export class PublicSummaryCache {
private cached?: { value: string; expiresAt: number };
private pending?: Promise<string>;
constructor(
private readonly load: () => Promise<string>,
private readonly now: () => number = Date.now,
) {}
read(): Promise<string> {
if (this.cached && this.now() < this.cached.expiresAt) {
return Promise.resolve(this.cached.value);
}
if (this.pending) return this.pending;
this.pending = Promise.resolve().then(() => this.load()).then(value => {
if (typeof value !== 'string' || Buffer.byteLength(value, 'utf8') > 1024) {
throw new Error('Invalid summary');
}
this.cached = { value, expiresAt: this.now() + 1000 };
return value;
}).finally(() => { this.pending = undefined; });
return this.pending;
}
}
Empty strings are valid hits. Failed or oversized loads are not cached, and pending callers share the same promise. Coalescing does not cancel a hung origin request. Enforce origin deadlines; size checks occur after the value is allocated. This process-local cache omits invalidation and authorization-aware keys needed for private data. Nest cache-manager/store TTL rules must be checked against their own versions.
Carry bounds to the data adapter
Validate the page request, then pass filtering, ordering, projection and the limit to persistence:
// titles.ts
import { Inject, Injectable } from '@nestjs/common';
export interface TitleRow { id: number; title: string }
export interface PageQuery {
fetch(request: {
tenant: string; afterId: number; limit: number;
select: readonly ['id', 'title']; order: 'id-asc';
}): Promise<TitleRow[]>;
}
export const PAGE_QUERY = Symbol('page-query');
@Injectable()
export class ListTitles {
constructor(@Inject(PAGE_QUERY) private readonly query: PageQuery) {}
async execute(tenant: string, afterId: number, limit: number): Promise<TitleRow[]> {
if (!/^[a-z0-9-]{1,32}$/.test(tenant) ||
!Number.isSafeInteger(afterId) || afterId < 0 ||
!Number.isSafeInteger(limit) || limit < 1 || limit > 50) {
throw new Error('Invalid page');
}
const rows = await this.query.fetch({
tenant, afterId, limit, select: ['id', 'title'], order: 'id-asc',
});
if (rows.length > limit) throw new Error('Adapter exceeded page limit');
let previous = afterId;
return rows.map(row => {
if (!Number.isSafeInteger(row.id) || row.id <= previous ||
typeof row.title !== 'string' || row.title.length > 200) {
throw new Error('Invalid page response');
}
previous = row.id;
return { id: row.id, title: row.title };
});
}
}
The caller supplies a trusted tenant identity. A fake adapter proves the requested contract and public-field projection. The real adapter must enforce authorization, parameterize inputs and apply these bounds in the database. Query plans and representative data establish the database cost.
Include first-use cost in lazy-loading decisions
Lazy loading defers provider construction to the first caller, including its cost and failure. A module in the same file still has its JavaScript evaluated eagerly. Nest also does not invoke lifecycle hooks in lazy-loaded modules/providers.
Compare startup, first use and steady state. An async method still runs its synchronous CPU work on Node's event loop. Neither a shorter function nor an async signature establishes a performance gain.
Apply these checks with NestJS Skills
NestJS Skills v2.5.0 adds 15 collection checks and 17 operations, DI and performance checks. A compatible coding agent can use the guidance to inspect these patterns, propose a bounded change and add regression tests.
| Skill | Practical review focus |
|---|---|
nestjs-architecture-principles |
Provider identity, DI and module boundaries |
nestjs-features-performance |
Runtime contracts and performance evidence |
nestjs-professional-software-engineering |
Focused implementation and verification |
Install from your project:
npx skills add amirtaherkhani/nestjs-skills
Give the agent a concrete contract:
Review this endpoint with NestJS Skills. Check repeated lookups, input
bounds, public output and provider lifetimes. Explain the tradeoffs.
Preserve ordering, duplicates and errors. Add regression tests for the
agreed change. Measure performance claims against the same workload.
From a checkout of the skills repository, with Node.js 20 or newer:
npm ci
npm run test:fixtures
npm run test:runtime
These run the collection and runtime reference suites. They do not run your application's tests or the additional article-snippet checks. The documentation explains how to apply the skills. Local checks establish teaching behavior; production latency, database plans, broker behavior and live shutdown need evidence from your application.
來源:DEV Community · dev.to