# 2026-05-20 NestJS forFeature 双实例陷阱 + Symbol token 防撞

## 背景

#410 PR-A 实现 `AgentToolsModule.forFeature([Service1, Service2])` 时
踩了 2 个真实坑 (ai-review 在 PR #474 第 2 轮找出来):

1. **useClass 双实例**: forFeature 中 `{ provide: s, useClass: s }` + 业务
   module 自己 `providers: [MyService]` → NestJS DI 在两个 module scope 各创建
   一个独立实例。controller 走业务 module 实例,bootstrap 走 `ModuleRef.get` 可能
   命中 @Global AgentToolsModule scope 的另一个实例 → 状态/缓存不一致。
2. **字符串 token 撞键**: `services.map(s=>s.name).join('_')` 跨包同名 class
   (e.g. 不同模块各有 `UserService`) 会撞 token,NestJS DI 容器键基于值相等性匹配
   会冲突。

## 根因 (3 层)

### 表面症状
ai-review 标 `forFeature useClass + 业务 module providers` 双实例 risk;
token 撞键 risk。

### 直接原因
1. **NestJS DynamicModule providers 数组创建新 provider scope**:
   `{ provide: s, useClass: s }` 即使 class 相同,NestJS 也会在该 module scope
   实例化一次;跨 module 不共享 (除非 @Global())。
2. **业务 module 同时 providers: [MyService]**:
   按 standards/21 §2 模板,业务 module 自己 `providers: [MyService]` 持有
   canonical 实例 → 跟 forFeature 的实例并存。
3. **字符串 token 容易撞**:
   `"AGENT_TOOLS_FORFEATURE__UserService"` 在 modules/iam/UserService 和
   modules/oms/UserService 都生成相同 token → NestJS DI key 冲突。

### 元根因
NestJS 没有像 Angular 那样的**原生 multi-provider 模式** (`{ multi: true }`
让多个 provider 同 token 累加成数组)。所有"forFeature 累加"模式都得绕弯:
- Token-per-call + 全局 collector singleton (本 PR-A 选项)
- DiscoveryService + metadata 过滤 (Q1.1 已否决)
- 静态 class state (并发 / 热重载脆弱)

绕弯都有边角:**累加器 + token 唯一**是相对最干净的,但 token 唯一性需保证。

## 解决方案 + Pattern

### Pattern: forFeature + 全局 collector + Symbol token + 业务 module 持 canonical 实例

```typescript
// agent-tools-collector.service.ts
@Injectable()
export class AgentToolsCollector {
  private readonly services = new Set<Type>();
  register(svcs: readonly Type[]): void {
    svcs.forEach(s => this.services.add(s));
  }
  list(): Type[] { return [...this.services]; }
}

// agent-tools.module.ts
@Global()
@Module({
  providers: [AgentToolsCollector],
  exports: [AgentToolsCollector],
})
export class AgentToolsModule {
  static forFeature(services: Type[]): DynamicModule {
    // ✅ Symbol() — 每次调用唯一引用, 即使同名 class 也不撞
    const collectFactoryToken = Symbol(
      `AGENT_TOOLS_FORFEATURE:${services.map(s => s.name).join(',')}`,
    );

    return {
      module: AgentToolsModule,
      providers: [
        {
          provide: collectFactoryToken,
          inject: [AgentToolsCollector],
          useFactory: (collector: AgentToolsCollector) => {
            collector.register(services);
            return services.length; // useFactory 必须返回非 undefined
          },
        },
      ],
      // ✅ 不 export services — 不创建额外实例; 业务 module 是 canonical owner
    };
  }
}

// 业务 module 模板 (standards/21 §2):
@Module({
  imports: [AgentToolsModule.forFeature([MyService])],
  providers: [MyService],     // ← canonical 实例由业务 module 持有
  exports: [MyService],
})
export class MyModule {}

// 启动期消费 (agent module 自己):
@Injectable()
export class AgentToolBootstrap implements OnApplicationBootstrap {
  constructor(
    private collector: AgentToolsCollector,
    private moduleRef: ModuleRef,
  ) {}

  async onApplicationBootstrap() {
    for (const cls of this.collector.list()) {
      // ✅ ModuleRef.get(strict: false) 跨 module 解析到业务 module 的 canonical 实例
      const instance = this.moduleRef.get(cls, { strict: false });
      if (!instance) continue;
      // ... 用 Reflect 读 @AgentTool 元数据 + 注册到 ToolRegistry
    }
  }
}
```

### 关键不变量

1. **forFeature 不 useClass / 不 exports services**: 只通过 Symbol token +
   useFactory 副作用累加 service class 引用到 collector
2. **业务 module 必须 providers: [...] 持 canonical 实例**: 不然 ModuleRef.get
   返回 undefined,bootstrap fail (有 warn log)
3. **Symbol token > 字符串 token**: 每次 forFeature 调用生成唯一 Symbol,
   跨包同名 class 不撞
4. **collector 是 @Global + 单例**: 多个 forFeature 调用都 register 到同一收集器
5. **ModuleRef.get strict: false**: 允许跨 module 解析 (默认 strict 会限制在当前 module)

## 跨任务可复用经验

1. **写 NestJS 累加器型 DynamicModule 第一选择是 Symbol token + 全局 collector**,
   不要试图用字符串 token 或 multi-provider 模式
2. **forFeature 的"做什么"决策**: 应该只做"声明" (push 到 collector),不做
   "实例化" (避免 useClass / useExisting 双实例陷阱);实例化交给业务 module
3. **bootstrap 时机用 OnApplicationBootstrap 而非 OnModuleInit**:
   forFeature 在 module init 阶段累加,bootstrap 阶段所有 module 都已 init 完
   collector 才 complete;OnModuleInit 时可能漏 module
4. **ModuleRef vs DiscoveryService**:
   - ModuleRef.get(cls) 精确取实例,推荐 (本场景已知 class 引用)
   - DiscoveryService 全量扫所有 provider 后 metadata 过滤,代价高 + Q1.1 否决
5. **NestJS DI 容器 token 类型**: string / Symbol / class 都可作 key;
   Symbol() 是相对最安全的"唯一引用" — 即使 description 相同,引用不等

## 相关链接

- docs/standards/21-agent-business-module-integration.md §2 业务 module 接入模板
- docs/standards/21-agent-business-module-integration.md §1.1 forFeature 显式收集决策
- PR #474 ai-review 第 2 轮 (双实例 + token 撞键 finding 原文)
- PR #474 commit `0280f2d4` (双实例 + token 撞键修复)
- backend/src/modules/agent/registry/agent-tools.module.ts (Pattern 实现)
- backend/src/modules/agent/registry/agent-tool-bootstrap.service.ts (Pattern 消费)
- NestJS DynamicModule docs: https://docs.nestjs.com/fundamentals/dynamic-modules
