隨著專案規模逐漸變大,模組與服務之間的交互會越來越頻繁。有時候,我們不知不覺中就會創造出一個「雞生蛋、蛋生雞」的窘境。
今天,我們就要來面對 NestJS 開發者幾乎都會撞見的經典錯誤——循環依賴(Circular Dependency)。
在 NestJS 中,當兩個以上的模組或服務互相需要對方,最後繞了一圈指回自己時,就發生了循環依賴。
想像一個影視製作組,裡面有兩個關鍵角色:
- 演員 (ActorService):「編劇,請先給我完整的劇本,我才決定要不要簽約接演。」
- 編劇 (WriterService):「演員,請先讓我確認是由誰演出,我才能根據你的形象寫出對應的台詞。」
當導演想要宣布開拍時,卻發現演員在等劇本,編劇在等演員。
雙方大眼瞪小眼,整部戲永遠無法開鏡。

forwardRef()使用 forwardRef() 就像是讓編劇和演員雙方先簽一份「合作意向書」。
大家先假設這部戲會順利開拍(延遲解析依賴),彼此先登記個名字,等到正式開機後(類別載入並定義完成後),再來慢慢磨合劇本與演出。

若是模組互相依賴,雙方 imports 都加上 forwardRef():
@Module({
imports: [forwardRef(() => UserModule)],
// ...
})
export class PostModule {}
若是服務互相注入,需在雙方建構子透過 @Inject() 搭配使用:
@Injectable()
export class PostService {
constructor(
@Inject(forwardRef(() => UserService))
private userService: UserService,
) {}
}
⚠️ 注意:
處理循環依賴時,必須「雙方」都加上forwardRef()。
雖然有時只加單邊程式也能跑,但那是因為當前框架的模組載入順序碰巧沒出錯,一旦調整了匯入順序,程式隨時會炸開。
雖然 forwardRef() 解決了依賴關係的「引用」問題,但實例化與生命週期執行的順序依然是不可預期的。
一旦你在 constructor 或 onModuleInit() 等生命週期 Hook 裡第一時間呼叫對方,就很可能拿到內部狀態還是初始值的物件。
讓我們用兩個互相注入並實作 OnModuleInit 生命週期 Hook 的服務來觀察這個現象:
@Injectable()
export class InitAService implements OnModuleInit {
public dataReady = false;
constructor(
@Inject(forwardRef(() => InitBService))
private readonly bService: InitBService,
) {}
onModuleInit() {
this.dataReady = true;
console.log(`[InitAService] B 的狀態: ${this.bService.dataReady}`);
}
}
@Injectable()
export class InitBService implements OnModuleInit {
public dataReady = false;
constructor(
@Inject(forwardRef(() => InitAService))
private readonly aService: InitAService,
) {}
onModuleInit() {
this.dataReady = true;
console.log(`[InitBService] A 的狀態: ${this.aService.dataReady}`);
}
}
實際執行時,你可能會在控制台看到這樣的輸出結果:
[InitAService] B 的狀態: false
[InitBService] A 的狀態: true
當 forwardRef() 循環依賴牽涉 Scope.REQUEST provider 時,Nest 會在請求進來後動態建立依賴子樹。由於建構順序不確定,其中一方可能拿到尚未完成建構的物件,甚至取得 undefined 依賴。
這種現象不一定在每個 Nest 版本與依賴圖中以相同方式出現,因此不要讓程式依賴循環兩邊 constructor 的執行順序。如果想深入理解實際發生過的問題,可參考 NestJS issue #5778
為了匯入方便,我們常在資料夾底下建立 index.ts 來統整匯出(稱作 Barrel File)。
但如果同一個資料夾內的 A 檔案,透過 index.ts 去引入同資料夾的 B 檔案,就會造成檔案層級的隱性循環依賴。這會導致模組載入時拿到尚未初始化完成的類別(undefined),進而引爆 NestJS 的 DI 錯誤——此時即使加上 forwardRef() 也無濟於事。
// index.ts
export * from './barrel-a.service';
export * from './barrel-b.service';
錯誤寫法:
// barrel-a.service.ts
import { BarrelBService } from '.'; // ❌ 從同目錄的 index.ts 引入,易引發循環載入
@Injectable()
export class BarrelAService {
constructor(private readonly bService: BarrelBService) {}
}
正確寫法:
// barrel-b.service.ts
import { BarrelAService } from './barrel-a.service'; // ✅ 直接引用具體檔案路徑
@Injectable()
export class BarrelBService {
constructor(private readonly aService: BarrelAService) {}
}
在架構設計上,互相依賴代表著模組之間的「高耦合」。
當你發現專案裡到處充滿了 forwardRef() 時,這其實是程式碼架構發出的強烈警訊:職責是否不清?是不是介入了彼此太多邏輯?
與其過度依賴 forwardRef(),更乾淨的做法是:
ModuleRef:讓其中一方改在執行期向 DI 容器動態索取對方。這雖然能解開依賴圖上的循環,但並未降低業務邏輯的耦合,且針對 Scope Provider 需額外處理上下文(詳細用法可參閱 Day 3)。forwardRef() 像一份「合作意向書」,讓雙方先登記名字、延後解析依賴。只宣告單邊通常只是運氣好避開載入順序,一旦調整匯入順序,依賴解析依然會失敗。forwardRef() 讓 Nest 能解析循環依賴中的類別參考,但不保證 constructor 或生命週期 Hook 的相對執行順序。Scope.REQUEST 會放大時序風險:當 request-scoped provider 位於循環依賴中,物件會在每個請求發生時動態建立,這會讓建構階段的時序與賦值變得不可靠。index.ts 造成的循環屬於檔案載入層級,會讓 NestJS 拿到未初始化的 undefined 類別而報錯。唯一解法是改為直接引用具體檔案路徑。forwardRef()應作為緊急止痛藥而非萬靈丹。當專案滿是 forwardRef() 時,應優先抽離共用服務或改用事件驅動進行徹底解耦。